diff --git a/docs/usage.rst b/docs/usage.rst index 699349a..07becb8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -3,6 +3,31 @@ Usage This package implements the Healthchecks.io Management and Ping APIs as documented here https://healthchecks.io/docs/api/. +Context Manager +--------------- + +Either the Client or AsyncClient can be used as a ContextManager (or Async Context Manager) + +.. code-block:: python + + from healthchecks_io import AsyncClient, CheckCreate + + async with AsyncClient(api_key="myapikey") as client: + check = await client.create_check(CreateCheck(name="New Check", tags="tag1 tag2") + print(check) + +This is probably the easiest way to use the Clients for one-off scripts. If you do not need to keep a client open for multiple requests, just use +the context manager. + +.. note:: + When using either of the client types as a context manager, the httpx client underlying the client will be closed when the context manager exits. + + Since we allow you to pass in a client on creation, its possible to use a shared client with this library. If you then use the client as a contextmanager, + it will close that shared client. + + Just a thing to be aware of! + + Sync ---- @@ -57,6 +82,7 @@ Pinging a Check ^^^^^^^^^^^^^^^ .. code-block:: python + from healthchecks_io import Client client = Client(api_key="myapikey") @@ -78,3 +104,39 @@ If you want to use the client in an async program, use AsyncClient instead of Cl check = await client.create_check(CreateCheck(name="New Check", tags="tag1 tag2") print(check) + + +CheckTrap +--------- + +Ever wanted to run some code and wrape it in a healthcheck check without thinking about it? + +That's what CheckTrap is for. + +.. code-block:: python + + from healthchecks_io import Client, AsyncClient, CheckCreate, CheckTrap + + client = Client(api_key="myapikey") + + # create a new check, or use an existing one already with just its uuid. + check = await client.create_check(CreateCheck(name="New Check", tags="tag1 tag2") + + with CheckTrap(client, check.uuid): + # when entering the context manager, sends a start ping to your check + run_my_thing_to_monitor() + + # If your method exits without an exception, sends a success ping + # If there's an exception, a failure ping will be sent with the exception and traceback + + client = AsyncClient(ping_key="ping_key") + + # works with async too, and the ping api and slugs + with CheckTrap(client, check.slug) as ct: + # when entering the context manager, sends a start ping to your check + # Add custom logs to what gets sent to healthchecks. Reminder, only the first 10k bytes get saved + ct.add_log("My custom log message") + run_my_thing_to_monitor() + + # If your method exits without an exception, sends a success ping + # If there's an exception, a failure ping will be sent with the exception and traceback diff --git a/poetry.lock b/poetry.lock index 001a005..9fa93d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,17 +45,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "babel" @@ -68,21 +68,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.1" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] - [[package]] name = "bandit" version = "1.7.1" @@ -261,11 +246,11 @@ pipenv = ["pipenv"] [[package]] name = "filelock" -version = "3.4.0" +version = "3.4.2" description = "A platform independent file lock." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] @@ -437,7 +422,7 @@ http2 = ["h2 (>=3,<5)"] [[package]] name = "identify" -version = "2.4.0" +version = "2.4.1" description = "File identification library for Python" category = "dev" optional = false @@ -603,11 +588,11 @@ flake8-polyfill = ">=1.0.2,<2" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -712,7 +697,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.10.0" +version = "2.11.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -834,7 +819,7 @@ python-versions = "*" [[package]] name = "pyupgrade" -version = "2.30.0" +version = "2.30.1" description = "A tool to automatically upgrade syntax for newer versions." category = "dev" optional = false @@ -918,7 +903,7 @@ idna2008 = ["idna"] [[package]] name = "ruamel.yaml" -version = "0.17.18" +version = "0.17.19" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" category = "dev" optional = false @@ -1190,7 +1175,7 @@ test = ["pytest", "typing-extensions", "mypy"] [[package]] name = "types-croniter" -version = "1.0.3" +version = "1.0.4" description = "Typing stubs for croniter" category = "dev" optional = false @@ -1227,14 +1212,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.10.0" +version = "20.11.2" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -1300,17 +1284,13 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, - {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, -] bandit = [ {file = "bandit-1.7.1-py3-none-any.whl", hash = "sha256:f5acd838e59c038a159b5c621cf0f8270b279e884eadd7b782d7491c02add0d4"}, {file = "bandit-1.7.1.tar.gz", hash = "sha256:a81b00b5436e6880fa8ad6799bc830e02032047713cbb143a12939ac67eb756c"}, @@ -1417,8 +1397,8 @@ dparse = [ {file = "dparse-0.5.1.tar.gz", hash = "sha256:a1b5f169102e1c894f9a7d5ccf6f9402a836a5d24be80a986c7ce9eaed78f367"}, ] filelock = [ - {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, - {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] flake8 = [ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, @@ -1468,8 +1448,8 @@ httpx = [ {file = "httpx-0.21.1.tar.gz", hash = "sha256:02af20df486b78892a614a7ccd4e4e86a5409ec4981ab0e422c579a887acad83"}, ] identify = [ - {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, - {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, + {file = "identify-2.4.1-py2.py3-none-any.whl", hash = "sha256:0192893ff68b03d37fed553e261d4a22f94ea974093aefb33b29df2ff35fed3c"}, + {file = "identify-2.4.1.tar.gz", hash = "sha256:64d4885e539f505dd8ffb5e93c142a1db45480452b1594cacd3e91dca9a984e9"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1619,8 +1599,8 @@ pep8-naming = [ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1675,8 +1655,8 @@ pyflakes = [ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, ] pygments = [ - {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, - {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, + {file = "Pygments-2.11.0-py3-none-any.whl", hash = "sha256:ac8098bfc40b8e1091ad7c13490c7f4797e401d0972e8fcfadde90ffb3ed4ea9"}, + {file = "Pygments-2.11.0.tar.gz", hash = "sha256:51130f778a028f2d19c143fce00ced6f8b10f726e17599d7e91b290f6cbcda0c"}, ] pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, @@ -1715,8 +1695,8 @@ pytz = [ {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, ] pyupgrade = [ - {file = "pyupgrade-2.30.0-py2.py3-none-any.whl", hash = "sha256:93dfabfb86ebbc511e7aab0775c6330897af85eab0048ab12cd2ae4e14d959ff"}, - {file = "pyupgrade-2.30.0.tar.gz", hash = "sha256:95c9986bff7f525b20986d6bd161bb14c223633a2035a292a8fa8f1936f3a7a4"}, + {file = "pyupgrade-2.30.1-py2.py3-none-any.whl", hash = "sha256:803c4e7631f0596e5d991b671014ce8f4f1e76b52b27dca11ea8bf0110808dfa"}, + {file = "pyupgrade-2.30.1.tar.gz", hash = "sha256:dc38f1613c50fd7baae3475f2afa5b8c933709df619494dd98773e91a5a2755e"}, ] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1773,14 +1753,10 @@ rfc3986 = [ {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] "ruamel.yaml" = [ - {file = "ruamel.yaml-0.17.18-py3-none-any.whl", hash = "sha256:9c648677803a2e9570c1116d15ba4fd89198c8966171868044bee2181cae8ab3"}, - {file = "ruamel.yaml-0.17.18.tar.gz", hash = "sha256:92b85e64a1d75adc29f941960f5a88dcf3d233a0ba0c3d0a864ca9645a9b7271"}, + {file = "ruamel.yaml-0.17.19-py3-none-any.whl", hash = "sha256:92ac00b312c9a83ff3253a8f7b86dfe6f9996b4082b103af84b8df99175945bc"}, + {file = "ruamel.yaml-0.17.19.tar.gz", hash = "sha256:b9ce9a925d0f0c35a1dbba56b40f253c53cd526b0fa81cf7b1d24996f28fb1d7"}, ] "ruamel.yaml.clib" = [ - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e7be2c5bcb297f5b82fee9c665eb2eb7001d1050deaba8471842979293a80b0"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:221eca6f35076c6ae472a531afa1c223b9c29377e62936f61bc8e6e8bdc5f9e7"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win32.whl", hash = "sha256:1070ba9dd7f9370d0513d649420c3b362ac2d687fe78c6e888f5b12bf8bc7bee"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:77df077d32921ad46f34816a9a16e6356d8100374579bc35e15bab5d4e9377de"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"}, @@ -1959,8 +1935,8 @@ typeguard = [ {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, ] types-croniter = [ - {file = "types-croniter-1.0.3.tar.gz", hash = "sha256:955d9ca3efaf99f0f76d6607a1f5b8a9cc4051e26dc34b4a3d69dfa29571758e"}, - {file = "types_croniter-1.0.3-py3-none-any.whl", hash = "sha256:45f5ca35db9c964c986ecbceea0a779219638b38a01f5346242da44a0013c3e1"}, + {file = "types-croniter-1.0.4.tar.gz", hash = "sha256:d366652365d034bf9c778dcdd7f0f2a550f60a680f5ea44609419deb2e8555e8"}, + {file = "types_croniter-1.0.4-py3-none-any.whl", hash = "sha256:d2ab4b182b10db03152d6056e239323c9254a92d4b0b810156b11795b3070713"}, ] types-pytz = [ {file = "types-pytz-2021.3.3.tar.gz", hash = "sha256:f6d21d6687935a1615db464b1e1df800d19502c36bc0486f43be7dfd2c404947"}, @@ -1975,8 +1951,8 @@ urllib3 = [ {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, - {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, + {file = "virtualenv-20.11.2-py2.py3-none-any.whl", hash = "sha256:efd556cec612fd826dc7ef8ce26a6e4ba2395f494244919acd135fb5ceffa809"}, + {file = "virtualenv-20.11.2.tar.gz", hash = "sha256:7f9e9c2e878d92a434e760058780b8d67a7c5ec016a66784fe4b0d5e50a4eb5c"}, ] xdoctest = [ {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, diff --git a/src/healthchecks_io/__init__.py b/src/healthchecks_io/__init__.py index 9f7a410..dc97a36 100644 --- a/src/healthchecks_io/__init__.py +++ b/src/healthchecks_io/__init__.py @@ -4,18 +4,22 @@ __version__ = "0.0.0" # noqa: E402 from .client import AsyncClient # noqa: F401, E402 from .client import Client # noqa: F401, E402 +from .client import CheckTrap # noqa: F401, E402 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 from .client.exceptions import HCAPIRateLimitError # noqa: F401, E402 from .client.exceptions import NonUniqueSlugError # noqa: F401, E402 +from .client.exceptions import WrongClientError # noqa: F401, E402 +from .client.exceptions import PingFailedError # noqa: F401, E402 from .schemas import Check, CheckCreate, CheckPings, CheckStatuses # noqa: F401, E402 from .schemas import Integration, Badges, CheckUpdate # noqa: F401, E402 __all__ = [ "AsyncClient", "Client", + "CheckTrap", "BadAPIRequestError", "CheckNotFoundError", "HCAPIAuthError", @@ -23,6 +27,8 @@ __all__ = [ "CheckNotFoundError", "HCAPIRateLimitError", "NonUniqueSlugError", + "WrongClientError", + "PingFailedError", "Check", "CheckCreate", "CheckUpdate", diff --git a/src/healthchecks_io/client/__init__.py b/src/healthchecks_io/client/__init__.py index 12a4e6b..22512f8 100644 --- a/src/healthchecks_io/client/__init__.py +++ b/src/healthchecks_io/client/__init__.py @@ -1,5 +1,6 @@ """healthchecks_io clients.""" from .async_client import AsyncClient # noqa: F401 +from .check_trap import CheckTrap # noqa: F401 from .sync_client import Client # noqa: F401 -__all__ = ["AsyncClient", "Client"] +__all__ = ["AsyncClient", "Client", "CheckTrap"] diff --git a/src/healthchecks_io/client/async_client.py b/src/healthchecks_io/client/async_client.py index 2076f9c..40d42ba 100644 --- a/src/healthchecks_io/client/async_client.py +++ b/src/healthchecks_io/client/async_client.py @@ -1,9 +1,11 @@ """An async healthchecks.io client.""" import asyncio +from types import TracebackType from typing import Dict from typing import List from typing import Optional from typing import Tuple +from typing import Type from httpx import AsyncClient as HTTPXAsyncClient @@ -56,6 +58,23 @@ class AsyncClient(AbstractClient): ] = f"py-healthchecks.io-async/{client_version}" self._client.headers["Content-type"] = "application/json" + async def __aenter__(self) -> "AsyncClient": + """Context manager entrance. + + Returns: + AsyncClient: returns this client as a context manager + """ + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """Context manager exit.""" + await self._afinalizer_method() + def _finalizer_method(self) -> None: """Calls _afinalizer_method from a sync context to work with weakref.finalizer.""" asyncio.run(self._afinalizer_method()) @@ -322,7 +341,9 @@ class AsyncClient(AbstractClient): for key, item in response.json()["badges"].items() } - async def success_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + async def success_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Signals to Healthchecks.io that a job has completed successfully. Can also be used to indicate a continuously running process is still running and healthy. @@ -338,6 +359,7 @@ class AsyncClient(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "". Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -352,10 +374,14 @@ class AsyncClient(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "") - response = self.check_ping_response(await self._client.get(ping_url)) + response = self.check_ping_response( + await self._client.post(ping_url, content=data) + ) return (True if response.status_code == 200 else False, response.text) - async def start_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + async def start_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Sends a "job has started!" message to Healthchecks.io. Sending a "start" signal is optional, but it enables a few extra features: @@ -373,6 +399,7 @@ class AsyncClient(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "". Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -387,10 +414,14 @@ class AsyncClient(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "/start") - response = self.check_ping_response(await self._client.get(ping_url)) + response = self.check_ping_response( + await self._client.post(ping_url, content=data) + ) return (True if response.status_code == 200 else False, response.text) - async def fail_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + async def fail_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Signals to Healthchecks.io that the job has failed. Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. @@ -406,6 +437,7 @@ class AsyncClient(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "". Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -420,11 +452,13 @@ class AsyncClient(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "/fail") - response = self.check_ping_response(await self._client.get(ping_url)) + response = self.check_ping_response( + await self._client.post(ping_url, content=data) + ) return (True if response.status_code == 200 else False, response.text) async def exit_code_ping( - self, exit_code: int, uuid: str = "", slug: str = "" + self, exit_code: int, uuid: str = "", slug: str = "", data: str = "" ) -> Tuple[bool, str]: """Signals to Healthchecks.io that the job has failed. @@ -442,6 +476,7 @@ class AsyncClient(AbstractClient): exit_code (int): Exit code to sent, int from 0 to 255 uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "". Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -456,5 +491,7 @@ class AsyncClient(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, f"/{exit_code}") - response = self.check_ping_response(await self._client.get(ping_url)) + response = self.check_ping_response( + await self._client.post(ping_url, content=data) + ) return (True if response.status_code == 200 else False, response.text) diff --git a/src/healthchecks_io/client/check_trap.py b/src/healthchecks_io/client/check_trap.py new file mode 100644 index 0000000..89bb040 --- /dev/null +++ b/src/healthchecks_io/client/check_trap.py @@ -0,0 +1,166 @@ +"""CheckTrap is a context manager to wrap around python code to communicate results to a Healthchecks check.""" +from types import TracebackType +from typing import List +from typing import Optional +from typing import Type +from typing import Union + +from .async_client import AsyncClient +from .exceptions import PingFailedError +from .exceptions import WrongClientError +from .sync_client import Client + + +class CheckTrap: + """CheckTrap is a context manager to wrap around python code to communicate results to a Healthchecks check.""" + + def __init__( + self, + client: Union[Client, AsyncClient], + uuid: str = "", + slug: str = "", + suppress_exceptions: bool = False, + ) -> None: + """A context manager to wrap around python code to communicate results to a Healthchecks check. + + Args: + client (Union[Client, AsyncClient]): healthchecks_io client, async or sync + uuid (str): uuid of the check. Defaults to "". + slug (str): slug of the check, exclusion wiht uuid. Defaults to "". + suppress_exceptions (bool): If true, do not raise any exceptions. Defaults to False. + + Raises: + Exception: Raised if a slug and a uuid is passed + """ + if uuid == "" and slug == "": + raise Exception("Must pass a slug or an uuid") + self.client: Union[Client, AsyncClient] = client + self.uuid: str = uuid + self.slug: str = slug + self.log_lines: List[str] = list() + self.suppress_exceptions: bool = suppress_exceptions + + def add_log(self, line: str) -> None: + """Add a line to the context manager's log that is sent with the check. + + Args: + line (str): String to add to the logs + """ + self.log_lines.append(line) + + def __enter__(self) -> "CheckTrap": + """Enter the context manager. + + Sends a start ping to the check represented by self.uuid or self.slug. + + Raises: + WrongClientError: Raised when using an AsyncClient with this as a sync client manager + PingFailedError: When a ping fails for any reason not handled by a custom exception + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + CheckTrap: self + """ + if isinstance(self.client, AsyncClient): + raise WrongClientError( + "You passed an AsyncClient, use this as an async context manager" + ) + result = self.client.start_ping(uuid=self.uuid, slug=self.slug) + if not result[0]: + raise PingFailedError(result[1]) + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> Optional[bool]: + """Exit the context manager. + + If there is an exception, add it to any log lines and send a fail ping. + Otherwise, send a success ping with any log lines appended. + + Args: + exc_type (Optional[Type[BaseException]]): [description] + exc (Optional[BaseException]): [description] + traceback (Optional[TracebackType]): [description] + + Returns: + Optional[bool]: self.suppress_exceptions, if true will not raise any exceptions + """ + if exc_type is None: + self.client.success_ping( + self.uuid, self.slug, data="\n".join(self.log_lines) + ) + else: + self.add_log(str(exc)) + self.add_log(str(traceback)) + self.client.fail_ping(self.uuid, self.slug, data="\n".join(self.log_lines)) + return self.suppress_exceptions + + async def __aenter__(self) -> "CheckTrap": + """Enter the context manager. + + Sends a start ping to the check represented by self.uuid or self.slug. + + Raises: + WrongClientError: Raised when using an AsyncClient with this as a sync client manager + PingFailedError: When a ping fails for any reason not handled by a custom exception + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + CheckTrap: self + """ + if isinstance(self.client, Client): + raise WrongClientError( + "You passed a sync Client, use this as a regular context manager" + ) + result = await self.client.start_ping(self.uuid, self.slug) + if not result[0]: + raise PingFailedError(result[1]) + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> Optional[bool]: + """Exit the context manager. + + If there is an exception, add it to any log lines and send a fail ping. + Otherwise, send a success ping with any log lines appended. + + Args: + exc_type (Optional[Type[BaseException]]): [description] + exc (Optional[BaseException]): [description] + traceback (Optional[TracebackType]): [description] + + Returns: + Optional[bool]: self.suppress_exceptions, if true will not raise any exceptions + """ + if exc_type is None: + # ignore typing, if we've gotten here we know its an async client + await self.client.success_ping( # type: ignore + self.uuid, self.slug, data="\n".join(self.log_lines) + ) + else: + self.add_log(str(exc)) + self.add_log(str(traceback)) + await self.client.fail_ping( # type: ignore + self.uuid, self.slug, data="\n".join(self.log_lines) + ) + return self.suppress_exceptions diff --git a/src/healthchecks_io/client/exceptions.py b/src/healthchecks_io/client/exceptions.py index c21b09a..6d28048 100644 --- a/src/healthchecks_io/client/exceptions.py +++ b/src/healthchecks_io/client/exceptions.py @@ -35,3 +35,15 @@ class NonUniqueSlugError(HCAPIError): """Thrown when the api returns a 409 when pinging.""" ... + + +class WrongClientError(HCAPIError): + """Thrown when trying to use a CheckTrap with the wrong client type.""" + + ... + + +class PingFailedError(HCAPIError): + """Thrown when a ping fails.""" + + ... diff --git a/src/healthchecks_io/client/sync_client.py b/src/healthchecks_io/client/sync_client.py index 3aa437e..8ee159b 100644 --- a/src/healthchecks_io/client/sync_client.py +++ b/src/healthchecks_io/client/sync_client.py @@ -1,8 +1,10 @@ """An async healthchecks.io client.""" +from types import TracebackType from typing import Dict from typing import List from typing import Optional from typing import Tuple +from typing import Type from httpx import Client as HTTPXClient @@ -50,6 +52,23 @@ class Client(AbstractClient): self._client.headers["user-agent"] = f"py-healthchecks.io/{client_version}" self._client.headers["Content-type"] = "application/json" + def __enter__(self) -> "Client": + """Context manager entrance. + + Returns: + Client: returns this client as a context manager + """ + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """Context manager exit.""" + self._finalizer_method() + def _finalizer_method(self) -> None: """Closes the httpx client.""" self._client.close() @@ -305,7 +324,9 @@ class Client(AbstractClient): for key, item in response.json()["badges"].items() } - def success_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + def success_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Signals to Healthchecks.io that a job has completed successfully. Can also be used to indicate a continuously running process is still running and healthy. @@ -321,6 +342,7 @@ class Client(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "" Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -335,10 +357,12 @@ class Client(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "") - response = self.check_ping_response(self._client.get(ping_url)) + response = self.check_ping_response(self._client.post(ping_url, content=data)) return (True if response.status_code == 200 else False, response.text) - def start_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + def start_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Sends a "job has started!" message to Healthchecks.io. Sending a "start" signal is optional, but it enables a few extra features: @@ -356,6 +380,7 @@ class Client(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "" Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -370,10 +395,12 @@ class Client(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "/start") - response = self.check_ping_response(self._client.get(ping_url)) + response = self.check_ping_response(self._client.post(ping_url, content=data)) return (True if response.status_code == 200 else False, response.text) - def fail_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + def fail_ping( + self, uuid: str = "", slug: str = "", data: str = "" + ) -> Tuple[bool, str]: """Signals to Healthchecks.io that the job has failed. Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. @@ -389,6 +416,7 @@ class Client(AbstractClient): Args: uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "" Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -403,11 +431,11 @@ class Client(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, "/fail") - response = self.check_ping_response(self._client.get(ping_url)) + response = self.check_ping_response(self._client.post(ping_url, content=data)) return (True if response.status_code == 200 else False, response.text) def exit_code_ping( - self, exit_code: int, uuid: str = "", slug: str = "" + self, exit_code: int, uuid: str = "", slug: str = "", data: str = "" ) -> Tuple[bool, str]: """Signals to Healthchecks.io that the job has failed. @@ -425,6 +453,7 @@ class Client(AbstractClient): exit_code (int): Exit code to sent, int from 0 to 255 uuid (str): Check's UUID. Defaults to "". slug (str): Check's Slug. Defaults to "". + data (str): Text data to append to this check. Defaults to "" Raises: HCAPIAuthError: Raised when status_code == 401 or 403 @@ -439,5 +468,5 @@ class Client(AbstractClient): Tuple[bool, str]: success (true or false) and the response text """ ping_url = self._get_ping_url(uuid, slug, f"/{exit_code}") - response = self.check_ping_response(self._client.get(ping_url)) + response = self.check_ping_response(self._client.post(ping_url, content=data)) return (True if response.status_code == 200 else False, response.text) diff --git a/tests/client/test_async.py b/tests/client/test_async.py index a9ecca7..79e3dce 100644 --- a/tests/client/test_async.py +++ b/tests/client/test_async.py @@ -14,6 +14,42 @@ 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_context_manager( + 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", + }, + ) + ) + async with test_async_client as test_client: + check = await test_client.create_check( + CheckCreate(name="test", tags="test", desc="test") + ) + assert check.name == "Backups" + + @pytest.mark.asyncio @pytest.mark.respx async def test_acreate_check_200(fake_check_api_result, respx_mock, test_async_client): @@ -357,9 +393,9 @@ ping_test_parameters = [ @pytest.mark.parametrize( "respx_mocker, tc, url, ping_method, method_kwargs", ping_test_parameters ) -async def test_success_ping(respx_mocker, tc, url, ping_method, method_kwargs): +async def test_asuccess_ping(respx_mocker, tc, url, ping_method, method_kwargs): channels_url = urljoin(tc._ping_url, url) - respx_mocker.get(channels_url).mock( + respx_mocker.post(channels_url).mock( return_value=Response(status_code=200, text="OK") ) ping_method = getattr(tc, ping_method) diff --git a/tests/client/test_check_trap.py b/tests/client/test_check_trap.py new file mode 100644 index 0000000..9a640f9 --- /dev/null +++ b/tests/client/test_check_trap.py @@ -0,0 +1,97 @@ +from urllib.parse import urljoin + +import pytest +import respx +from httpx import Client as HTTPXClient +from httpx import Response + +from healthchecks_io import CheckCreate +from healthchecks_io import CheckTrap +from healthchecks_io import CheckUpdate +from healthchecks_io import PingFailedError +from healthchecks_io import WrongClientError + + +@pytest.mark.respx +def test_check_trap_sync(respx_mock, test_client): + start_url = urljoin(test_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=200, text="OK")) + success_url = urljoin(test_client._ping_url, "test") + respx_mock.post(success_url).mock(return_value=Response(status_code=200, text="OK")) + + with CheckTrap(test_client, uuid="test") as ct: + pass + + +@pytest.mark.respx +def test_check_trap_sync_failed_ping(respx_mock, test_client): + start_url = urljoin(test_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=444, text="OK")) + with pytest.raises(PingFailedError): + with CheckTrap(test_client, uuid="test") as ct: + pass + + +@pytest.mark.respx +def test_check_trap_sync_exception(respx_mock, test_client): + start_url = urljoin(test_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=200, text="OK")) + fail_url = urljoin(test_client._ping_url, "test/fail") + respx_mock.post(fail_url).mock(return_value=Response(status_code=200, text="OK")) + with pytest.raises(Exception): + with CheckTrap(test_client, uuid="test") as ct: + raise Exception("Exception") + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_check_trap_async(respx_mock, test_async_client): + start_url = urljoin(test_async_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=200, text="OK")) + success_url = urljoin(test_async_client._ping_url, "test") + respx_mock.post(success_url).mock(return_value=Response(status_code=200, text="OK")) + + async with CheckTrap(test_async_client, uuid="test") as ct: + pass + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_check_trap_async_failed_ping(respx_mock, test_async_client): + start_url = urljoin(test_async_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=444, text="OK")) + with pytest.raises(PingFailedError): + async with CheckTrap(test_async_client, uuid="test") as ct: + pass + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_check_trap_async_exception(respx_mock, test_async_client): + start_url = urljoin(test_async_client._ping_url, "test/start") + respx_mock.post(start_url).mock(return_value=Response(status_code=200, text="OK")) + fail_url = urljoin(test_async_client._ping_url, "test/fail") + respx_mock.post(fail_url).mock(return_value=Response(status_code=200, text="OK")) + + with pytest.raises(Exception): + async with CheckTrap(test_async_client, uuid="test") as ct: + raise Exception("Exception") + + +@pytest.mark.asyncio +async def test_check_trap_wrong_client_error(test_client, test_async_client): + + with pytest.raises(WrongClientError): + async with CheckTrap(test_client, uuid="test") as ct: + pass + + with pytest.raises(WrongClientError): + with CheckTrap(test_async_client, uuid="test") as ct: + pass + + +def test_check_trap_no_uuid_or_slug(test_client): + with pytest.raises(Exception) as exc: + with CheckTrap(test_client): + pass + assert str(exc) == "Must pass a slug or an uuid" diff --git a/tests/client/test_sync.py b/tests/client/test_sync.py index 853b656..e5da7ca 100644 --- a/tests/client/test_sync.py +++ b/tests/client/test_sync.py @@ -14,6 +14,39 @@ from healthchecks_io.client.exceptions import HCAPIAuthError from healthchecks_io.client.exceptions import HCAPIError +@pytest.mark.respx +def test_create_check_200_context_manager( + 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", + }, + ) + ) + with test_client as tc: + check = tc.create_check(CheckCreate(name="test", tags="test", desc="test")) + assert check.name == "Backups" + + @pytest.mark.respx def test_create_check_200(fake_check_api_result, respx_mock, test_client): checks_url = urljoin(test_client._api_url, "checks/") @@ -325,7 +358,7 @@ ping_test_parameters = [ ) def test_success_ping(respx_mocker, tc, url, ping_method, method_kwargs): channels_url = urljoin(tc._ping_url, url) - respx_mocker.get(channels_url).mock( + respx_mocker.post(channels_url).mock( return_value=Response(status_code=200, text="OK") ) ping_method = getattr(tc, ping_method)