From d73ff88d6042de3f8daf6375dbff68cadbd95633 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Wed, 15 Dec 2021 18:46:35 -0600 Subject: [PATCH] creates and updates and a start on docs --- README.md | 3 - README.rst | 14 ++-- docs/usage.rst | 2 + src/healthchecks_io/__init__.py | 9 ++ src/healthchecks_io/client/async_client.py | 97 ++++++++++++++++------ src/healthchecks_io/client/sync_client.py | 46 ++++++++++ src/healthchecks_io/schemas/__init__.py | 17 ++++ src/healthchecks_io/schemas/checks.py | 67 +++++++++++++++ tests/client/test_async.py | 68 +++++++++++++++ tests/client/test_sync.py | 62 ++++++++++++++ 10 files changed, 350 insertions(+), 35 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index c97d191..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# py-healthchecks.io - -A python client for healthchecks.io. Supports the management api and ping api diff --git a/README.rst b/README.rst index 443d84d..fc08770 100644 --- a/README.rst +++ b/README.rst @@ -35,18 +35,22 @@ Py Healthchecks.Io :target: https://github.com/psf/black :alt: Black -A python client for healthchecks.io. Supports the management api and ping api +A python client for healthchecks.io. Supports the management api and ping api. Features -------- -* TODO +* Sync and Async clients based on HTTPX +* Supports the management api and the ping api +* Supports Healthchecks.io SAAS and self-hosted instances Requirements ------------ -* TODO +* httpx +* pytz +* pydantic Installation @@ -62,7 +66,7 @@ You can install *Py Healthchecks.Io* via pip_ from PyPI_: Usage ----- -Please see the `Command-line Reference `_ for details. +Please see the `Usage `_ for details. Contributing @@ -100,4 +104,4 @@ This project was generated from `@cjolowicz`_'s `Hypermodern Python Cookiecutter .. _pip: https://pip.pypa.io/ .. github-only .. _Contributor Guide: CONTRIBUTING.rst -.. _Usage: https://py-healthchecks.io.readthedocs.io/en/latest/usage.html +.. _Usage: https://py-healthchecksio.readthedocs.io/en/latest/usage.html diff --git a/docs/usage.rst b/docs/usage.rst index d4b9a15..6c60943 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,2 +1,4 @@ Usage ===== + +After installation, you can import and instantiate a client and then use the diff --git a/src/healthchecks_io/__init__.py b/src/healthchecks_io/__init__.py index ad1c20b..74e9e2d 100644 --- a/src/healthchecks_io/__init__.py +++ b/src/healthchecks_io/__init__.py @@ -9,6 +9,8 @@ from .client.exceptions import HCAPIAuthError # noqa: F401, E402 from .client.exceptions import HCAPIError # noqa: F401, E402 from .client.exceptions import HCAPIRateLimitError # noqa: F401, E402 from .client.exceptions import NonUniqueSlugError # noqa: F401, E402 +from .schemas import Check, CheckCreate, CheckPings, CheckStatuses # noqa: F401, E402 +from .schemas import Integration, Badges, CheckUpdate # noqa: F401, E402 __all__ = [ "AsyncClient", @@ -20,4 +22,11 @@ __all__ = [ "CheckNotFoundError", "HCAPIRateLimitError", "NonUniqueSlugError", + "Check", + "CheckCreate", + "CheckUpdate", + "CheckPings", + "CheckStatuses", + "Integration", + "Badges", ] diff --git a/src/healthchecks_io/client/async_client.py b/src/healthchecks_io/client/async_client.py index 2383cd0..731e7c8 100644 --- a/src/healthchecks_io/client/async_client.py +++ b/src/healthchecks_io/client/async_client.py @@ -9,9 +9,12 @@ from httpx import AsyncClient as HTTPXAsyncClient from ._abstract import AbstractClient from healthchecks_io import VERSION -from healthchecks_io.schemas import badges -from healthchecks_io.schemas import checks -from healthchecks_io.schemas import integrations +from healthchecks_io.schemas import Badges +from healthchecks_io.schemas import Check +from healthchecks_io.schemas import CheckCreate +from healthchecks_io.schemas import CheckPings +from healthchecks_io.schemas import CheckStatuses +from healthchecks_io.schemas import Integration class AsyncClient(AbstractClient): @@ -59,7 +62,47 @@ class AsyncClient(AbstractClient): """Finalizer coroutine that closes our client connections.""" await self._client.aclose() - async def get_checks(self, tags: Optional[List[str]] = None) -> List[checks.Check]: + async def create_check(self, new_check: CheckCreate) -> Check: + """Creates a new check and returns it. + + With this API call, you can create both Simple and Cron checks: + * To create a Simple check, specify the timeout parameter. + * To create a Cron check, specify the schedule and tz parameters. + + Args: + new_check (CheckCreate): New check you are wanting to create + + Returns: + Check: check that was just created + """ + request_url = self._get_api_request_url("checks/") + response = self.check_response( + await self._client.post(request_url, json=new_check.dict()) + ) + return Check.from_api_result(response.json()) + + async def update_check(self, uuid: str, update_check: CheckCreate) -> Check: + """Updates an existing check. + + If you omit any parameter in update_check, Healthchecks.io will leave + its value unchanged. + + Args: + uuid (str): UUID for the check to update + update_check (CheckCreate): Check values you want to update + + Returns: + Check: check that was just updated + """ + request_url = self._get_api_request_url(f"checks/{uuid}") + response = self.check_response( + await self._client.post( + request_url, json=update_check.dict(exclude_unset=True) + ) + ) + return Check.from_api_result(response.json()) + + async def get_checks(self, tags: Optional[List[str]] = None) -> List[Check]: """Get a list of checks from the healthchecks api. Args: @@ -73,7 +116,7 @@ class AsyncClient(AbstractClient): Returns: - List[checks.Check]: [description] + List[Check]: [description] """ request_url = self._get_api_request_url("checks/") if tags is not None: @@ -85,11 +128,11 @@ class AsyncClient(AbstractClient): response = self.check_response(await self._client.get(request_url)) return [ - checks.Check.from_api_result(check_data) + Check.from_api_result(check_data) for check_data in response.json()["checks"] ] - async def get_check(self, check_id: str) -> checks.Check: + async def get_check(self, check_id: str) -> Check: """Get a single check by id. check_id can either be a check uuid if using a read/write api key @@ -99,7 +142,7 @@ class AsyncClient(AbstractClient): check_id (str): check's uuid or unique id Returns: - checks.Check: the check + Check: the check Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -111,9 +154,9 @@ class AsyncClient(AbstractClient): """ request_url = self._get_api_request_url(f"checks/{check_id}") response = self.check_response(await self._client.get(request_url)) - return checks.Check.from_api_result(response.json()) + return Check.from_api_result(response.json()) - async def pause_check(self, check_id: str) -> checks.Check: + async def pause_check(self, check_id: str) -> Check: """Disables monitoring for a check without removing it. The check goes into a "paused" state. @@ -125,7 +168,7 @@ class AsyncClient(AbstractClient): check_id (str): check's uuid Returns: - checks.Check: the check just paused + Check: the check just paused Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -135,9 +178,9 @@ class AsyncClient(AbstractClient): """ request_url = self._get_api_request_url(f"checks/{check_id}/pause") response = self.check_response(await self._client.post(request_url, data={})) - return checks.Check.from_api_result(response.json()) + return Check.from_api_result(response.json()) - async def delete_check(self, check_id: str) -> checks.Check: + async def delete_check(self, check_id: str) -> Check: """Permanently deletes the check from the user's account. check_id must be a uuid, not a unique id @@ -146,7 +189,7 @@ class AsyncClient(AbstractClient): check_id (str): check's uuid Returns: - checks.Check: the check just deleted + Check: the check just deleted Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -157,9 +200,9 @@ class AsyncClient(AbstractClient): """ request_url = self._get_api_request_url(f"checks/{check_id}") response = self.check_response(await self._client.delete(request_url)) - return checks.Check.from_api_result(response.json()) + return Check.from_api_result(response.json()) - async def get_check_pings(self, check_id: str) -> List[checks.CheckPings]: + async def get_check_pings(self, check_id: str) -> List[CheckPings]: """Returns a list of pings this check has received. This endpoint returns pings in reverse order (most recent first), @@ -170,7 +213,7 @@ class AsyncClient(AbstractClient): check_id (str): check's uuid Returns: - List[checks.CheckPings]: list of pings this check has received + List[CheckPings]: list of pings this check has received Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -183,7 +226,7 @@ class AsyncClient(AbstractClient): request_url = self._get_api_request_url(f"checks/{check_id}/pings/") response = self.check_response(await self._client.get(request_url)) return [ - checks.CheckPings.from_api_result(check_data) + CheckPings.from_api_result(check_data) for check_data in response.json()["pings"] ] @@ -193,7 +236,7 @@ class AsyncClient(AbstractClient): seconds: Optional[int] = None, start: Optional[int] = None, end: Optional[int] = None, - ) -> List[checks.CheckStatuses]: + ) -> List[CheckStatuses]: """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"). @@ -213,7 +256,7 @@ class AsyncClient(AbstractClient): 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 + List[CheckStatuses]: List of status flips for this check """ params = dict() @@ -226,9 +269,9 @@ class AsyncClient(AbstractClient): request_url = self._get_api_request_url(f"checks/{check_id}/flips/", params) response = self.check_response(await self._client.get(request_url)) - return [checks.CheckStatuses(**status_data) for status_data in response.json()] + return [CheckStatuses(**status_data) for status_data in response.json()] - async def get_integrations(self) -> List[Optional[integrations.Integration]]: + async def get_integrations(self) -> List[Optional[Integration]]: """Returns a list of integrations belonging to the project. Raises: @@ -237,17 +280,17 @@ class AsyncClient(AbstractClient): HCAPIRateLimitError: Raised when status code is 429 Returns: - List[Optional[integrations.Integration]]: List of integrations for the project + List[Optional[Integration]]: List of integrations for the project """ request_url = self._get_api_request_url("channels/") response = self.check_response(await self._client.get(request_url)) return [ - integrations.Integration.from_api_result(integration_dict) + Integration.from_api_result(integration_dict) for integration_dict in response.json()["channels"] ] - async def get_badges(self) -> Dict[str, badges.Badges]: + async def get_badges(self) -> Dict[str, Badges]: """Returns a dict of all tags in the project, with badge URLs for each tag. Healthchecks.io provides badges in a few different formats: @@ -268,12 +311,12 @@ class AsyncClient(AbstractClient): HCAPIRateLimitError: Raised when status code is 429 Returns: - Dict[str, badges.Badges]: Dictionary of all tags in the project with badges + Dict[str, 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)) return { - key: badges.Badges.from_api_result(item) + key: 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 6594c8d..1a3b0b1 100644 --- a/src/healthchecks_io/client/sync_client.py +++ b/src/healthchecks_io/client/sync_client.py @@ -9,6 +9,8 @@ from httpx import Client as HTTPXClient from ._abstract import AbstractClient from healthchecks_io import VERSION from healthchecks_io.schemas import badges +from healthchecks_io.schemas import Check +from healthchecks_io.schemas import CheckCreate from healthchecks_io.schemas import checks from healthchecks_io.schemas import integrations @@ -102,6 +104,50 @@ class Client(AbstractClient): response = self.check_response(self._client.get(request_url)) return checks.Check.from_api_result(response.json()) + def create_check(self, new_check: CheckCreate) -> Check: + """Creates a new check and returns it. + + With this API call, you can create both Simple and Cron checks: + * To create a Simple check, specify the timeout parameter. + * To create a Cron check, specify the schedule and tz parameters. + + Args: + new_check (CheckCreate): New check you are wanting to create + + Returns: + Check: check that was just created + """ + request_url = self._get_api_request_url("checks/") + response = self.check_response( + self._client.post(request_url, json=new_check.dict()) + ) + return Check.from_api_result(response.json()) + + def update_check(self, uuid: str, update_check: CheckCreate) -> Check: + """Updates an existing check. + + If you omit any parameter in update_check, Healthchecks.io will leave + its value unchanged. + + + + With this API call, you can create both Simple and Cron checks: + * To create a Simple check, specify the timeout parameter. + * To create a Cron check, specify the schedule and tz parameters. + + Args: + uuid (str): UUID for the check to update + update_check (CheckCreate): Check values you want to update + + Returns: + Check: check that was just updated + """ + request_url = self._get_api_request_url(f"checks/{uuid}") + response = self.check_response( + self._client.post(request_url, json=update_check.dict(exclude_unset=True)) + ) + return Check.from_api_result(response.json()) + def pause_check(self, check_id: str) -> checks.Check: """Disables monitoring for a check without removing it. diff --git a/src/healthchecks_io/schemas/__init__.py b/src/healthchecks_io/schemas/__init__.py index 991b605..6041141 100644 --- a/src/healthchecks_io/schemas/__init__.py +++ b/src/healthchecks_io/schemas/__init__.py @@ -1 +1,18 @@ """Schemas for healthchecks_io.""" +from .badges import Badges +from .checks import Check +from .checks import CheckCreate +from .checks import CheckPings +from .checks import CheckStatuses +from .checks import CheckUpdate +from .integrations import Integration + +__all__ = [ + "Check", + "CheckCreate", + "CheckPings", + "CheckUpdate", + "CheckStatuses", + "Badges", + "Integration", +] diff --git a/src/healthchecks_io/schemas/checks.py b/src/healthchecks_io/schemas/checks.py index b6ab386..8144777 100644 --- a/src/healthchecks_io/schemas/checks.py +++ b/src/healthchecks_io/schemas/checks.py @@ -163,6 +163,73 @@ class CheckCreate(BaseModel): return value +class CheckUpdate(CheckCreate): + """Pydantic object for updating a check.""" + + name: Optional[str] = Field(None, description="Name of the check") + tags: Optional[str] = Field( + None, description="String separated list of tags to apply" + ) + timeout: Optional[int] = Field( + None, + description="The expected period of this check in seconds.", + gte=60, + lte=31536000, + ) + grace: Optional[int] = Field( + None, + description="The grace period for this check in seconds.", + gte=60, + lte=31536000, + ) + schedule: Optional[str] = Field( + None, + description="A cron expression defining this check's schedule. " + "If you specify both timeout and schedule parameters, " + "Healthchecks.io will create a Cron check and ignore the " + "timeout value.", + ) + tz: Optional[str] = Field( + None, + description="Server's timezone. This setting only has an effect " + "in combination with the schedule parameter.", + ) + manual_resume: Optional[bool] = Field( + None, + description="Controls whether a paused check automatically resumes " + "when pinged (the default) or not. If set to false, a paused " + "check will leave the paused state when it receives a ping. " + "If set to true, a paused check will ignore pings and stay " + "paused until you manually resume it from the web dashboard.", + ) + methods: Optional[str] = Field( + None, + description="Specifies the allowed HTTP methods for making " + "ping requests. Must be one of the two values: an empty " + "string or POST. Set this field to an empty string to " + "allow HEAD, GET, and POST requests. Set this field to " + "POST to allow only POST requests.", + ) + channels: Optional[str] = Field( + None, + description="By default, this API call assigns no integrations" + "to the newly created check. By default, this API call " + "assigns no integrations to the newly created check. " + "To assign specific integrations, use a comma-separated list " + "of integration UUIDs.", + ) + unique: Optional[List[Optional[str]]] = Field( + None, + description="Enables upsert functionality. Before creating a check, " + "Healthchecks.io looks for existing checks, filtered by fields listed " + "in unique. If Healthchecks.io does not find a matching check, it " + "creates a new check and returns it with the HTTP status code 201 " + "If Healthchecks.io finds a matching check, it updates the existing " + "check and returns it with HTTP status code 200. The accepted values " + "for the unique field are name, tags, timeout, and grace.", + ) + + class CheckPings(BaseModel): """A Pydantic schema for a check's Pings.""" diff --git a/tests/client/test_async.py b/tests/client/test_async.py index 1d68e7b..a9ecca7 100644 --- a/tests/client/test_async.py +++ b/tests/client/test_async.py @@ -5,6 +5,8 @@ import respx from httpx import AsyncClient as HTTPXAsyncClient from httpx import Response +from healthchecks_io import CheckCreate +from healthchecks_io import CheckUpdate from healthchecks_io.client import AsyncClient from healthchecks_io.client.exceptions import BadAPIRequestError from healthchecks_io.client.exceptions import CheckNotFoundError @@ -12,6 +14,72 @@ from healthchecks_io.client.exceptions import HCAPIAuthError from healthchecks_io.client.exceptions import HCAPIError +@pytest.mark.asyncio +@pytest.mark.respx +async def test_acreate_check_200(fake_check_api_result, respx_mock, test_async_client): + checks_url = urljoin(test_async_client._api_url, "checks/") + respx_mock.post(checks_url).mock( + return_value=Response( + status_code=200, + json={ + "channels": "", + "desc": "", + "grace": 60, + "last_ping": None, + "n_pings": 0, + "name": "Backups", + "slug": "backups", + "next_ping": None, + "manual_resume": False, + "methods": "", + "pause_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pause", + "ping_url": "https://hc-ping.com/f618072a-7bde-4eee-af63-71a77c5723bc", + "status": "new", + "tags": "prod www", + "timeout": 3600, + "update_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", + }, + ) + ) + check = await test_async_client.create_check( + CheckCreate(name="test", tags="test", desc="test") + ) + assert check.name == "Backups" + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_aupdate_check_200(fake_check_api_result, respx_mock, test_async_client): + checks_url = urljoin(test_async_client._api_url, "checks/test") + respx_mock.post(checks_url).mock( + return_value=Response( + status_code=200, + json={ + "channels": "", + "desc": "", + "grace": 60, + "last_ping": None, + "n_pings": 0, + "name": "Backups", + "slug": "backups", + "next_ping": None, + "manual_resume": False, + "methods": "", + "pause_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pause", + "ping_url": "https://hc-ping.com/f618072a-7bde-4eee-af63-71a77c5723bc", + "status": "new", + "tags": "prod www", + "timeout": 3600, + "update_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", + }, + ) + ) + check = await test_async_client.update_check( + "test", CheckUpdate(name="test", desc="test") + ) + assert check.name == "Backups" + + @pytest.mark.asyncio @pytest.mark.respx async def test_aget_checks_200(fake_check_api_result, respx_mock, test_async_client): diff --git a/tests/client/test_sync.py b/tests/client/test_sync.py index b4bf877..853b656 100644 --- a/tests/client/test_sync.py +++ b/tests/client/test_sync.py @@ -5,6 +5,8 @@ import respx from httpx import Client as HTTPXClient from httpx import Response +from healthchecks_io import CheckCreate +from healthchecks_io import CheckUpdate from healthchecks_io import Client from healthchecks_io.client.exceptions import BadAPIRequestError from healthchecks_io.client.exceptions import CheckNotFoundError @@ -12,6 +14,66 @@ from healthchecks_io.client.exceptions import HCAPIAuthError from healthchecks_io.client.exceptions import HCAPIError +@pytest.mark.respx +def test_create_check_200(fake_check_api_result, respx_mock, test_client): + checks_url = urljoin(test_client._api_url, "checks/") + respx_mock.post(checks_url).mock( + return_value=Response( + status_code=200, + json={ + "channels": "", + "desc": "", + "grace": 60, + "last_ping": None, + "n_pings": 0, + "name": "Backups", + "slug": "backups", + "next_ping": None, + "manual_resume": False, + "methods": "", + "pause_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pause", + "ping_url": "https://hc-ping.com/f618072a-7bde-4eee-af63-71a77c5723bc", + "status": "new", + "tags": "prod www", + "timeout": 3600, + "update_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", + }, + ) + ) + check = test_client.create_check(CheckCreate(name="test", tags="test", desc="test")) + assert check.name == "Backups" + + +@pytest.mark.respx +def test_update_check_200(fake_check_api_result, respx_mock, test_client): + checks_url = urljoin(test_client._api_url, "checks/test") + respx_mock.post(checks_url).mock( + return_value=Response( + status_code=200, + json={ + "channels": "", + "desc": "", + "grace": 60, + "last_ping": None, + "n_pings": 0, + "name": "Backups", + "slug": "backups", + "next_ping": None, + "manual_resume": False, + "methods": "", + "pause_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pause", + "ping_url": "https://hc-ping.com/f618072a-7bde-4eee-af63-71a77c5723bc", + "status": "new", + "tags": "prod www", + "timeout": 3600, + "update_url": "https://healthchecks.io/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", + }, + ) + ) + check = test_client.update_check("test", CheckUpdate(name="test", desc="test")) + assert check.name == "Backups" + + @pytest.mark.respx def test_get_checks_200(fake_check_api_result, respx_mock, test_client): assert test_client._client is not None