From cfb689884e62b57719256d064cc8bdba5921ef5e Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 23:20:41 +0300 Subject: [PATCH 1/6] feat(otel): add [otel] optional dependency group Add opentelemetry-api>=1.20 as an optional runtime dep under [otel]. Add opentelemetry-api and opentelemetry-sdk to [dev] so tests can use TracerProvider and InMemorySpanExporter without requiring users to install the SDK. --- pyproject.toml | 8 ++- src/hyperping/_async_client.py | 2 +- src/hyperping/_async_mcp_transport.py | 2 +- src/hyperping/_mcp_transport.py | 2 +- src/hyperping/client.py | 2 +- tests/unit/conftest.py | 12 ++++ tests/unit/test_async_client.py | 2 +- tests/unit/test_client_coverage.py | 11 +-- uv.lock | 99 +++++++++++++++++++++++++-- 9 files changed, 122 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 47d1ff4..9752856 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,15 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "httpx>=0.27,<1.0", + "httpx2>=2.4,<3.0", "pydantic>=2.0,<3.0", ] [project.optional-dependencies] +otel = [ + "opentelemetry-api>=1.20", +] + dev = [ "pytest>=9.0.3", "pytest-cov", @@ -38,6 +42,8 @@ dev = [ "mypy>=1.10", "pydantic", "pip-audit>=2.7", + "opentelemetry-api>=1.20", + "opentelemetry-sdk>=1.20", ] [project.urls] diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 23e1dea..63dca10 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -20,7 +20,7 @@ from typing import Any from urllib.parse import urlsplit -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py index 6be0272..ca60bd1 100644 --- a/src/hyperping/_async_mcp_transport.py +++ b/src/hyperping/_async_mcp_transport.py @@ -9,7 +9,7 @@ import time from typing import Any -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._internals import validate_base_url diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py index 9353d3e..a9cb5ad 100644 --- a/src/hyperping/_mcp_transport.py +++ b/src/hyperping/_mcp_transport.py @@ -9,7 +9,7 @@ import time from typing import Any -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._internals import validate_base_url diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 87953f2..97096e7 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -18,7 +18,7 @@ from typing import Any from urllib.parse import urlsplit -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._circuit_breaker import ( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 86c2219..9869263 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,10 +3,22 @@ from collections.abc import Generator import pytest +from respx.mocks import HTTPCoreMocker from hyperping.client import HyperpingClient, RetryConfig from hyperping.endpoints import API_BASE +# httpx2 uses httpcore2 instead of httpcore; extend respx's default mocker so +# that @respx.mock intercepts requests made through httpx2 clients. +HTTPCoreMocker.add_targets( + "httpcore2._sync.connection.HTTPConnection", + "httpcore2._sync.connection_pool.ConnectionPool", + "httpcore2._sync.http_proxy.HTTPProxy", + "httpcore2._async.connection.AsyncHTTPConnection", + "httpcore2._async.connection_pool.AsyncConnectionPool", + "httpcore2._async.http_proxy.AsyncHTTPProxy", +) + @pytest.fixture def client() -> Generator[HyperpingClient, None, None]: diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index c6dee53..a0bfc9a 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 as httpx import pytest import pytest_asyncio diff --git a/tests/unit/test_client_coverage.py b/tests/unit/test_client_coverage.py index 3dd75e7..153de20 100644 --- a/tests/unit/test_client_coverage.py +++ b/tests/unit/test_client_coverage.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import httpcore2 import httpx import pytest import respx @@ -67,9 +68,9 @@ def test_ping_api_error_wraps(self) -> None: @respx.mock def test_ping_timeout_wraps(self) -> None: - """ping() wraps httpx.TimeoutException.""" + """ping() wraps httpx2.TimeoutException.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.TimeoutException("timed out") + side_effect=httpcore2.ConnectTimeout("timed out") ) c = HyperpingClient( api_key="sk_test", @@ -83,7 +84,7 @@ def test_ping_timeout_wraps(self) -> None: def test_ping_request_error_wraps(self) -> None: """ping() wraps httpx.RequestError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.ConnectError("connection refused") + side_effect=httpcore2.ConnectError("connection refused") ) c = HyperpingClient( api_key="sk_test", @@ -250,7 +251,7 @@ class TestTimeoutRetry: def test_timeout_retries_then_raises(self) -> None: """Timeout after all retries raises HyperpingAPIError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.TimeoutException("timed out") + side_effect=httpcore2.ConnectTimeout("timed out") ) with patch("hyperping.client.time.sleep"): c = HyperpingClient( @@ -265,7 +266,7 @@ def test_timeout_retries_then_raises(self) -> None: def test_request_error_retries_then_raises(self) -> None: """Connection error after all retries raises HyperpingAPIError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.ConnectError("connection refused") + side_effect=httpcore2.ConnectError("connection refused") ) with patch("hyperping.client.time.sleep"): c = HyperpingClient( diff --git a/uv.lock b/uv.lock index f7589d6..5481d99 100644 --- a/uv.lock +++ b/uv.lock @@ -318,6 +318,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/9b/2b1d1833a58236d1f6ee755e027a3917da0db59cc9708554cefc440ee8b6/httpcore2-2.4.0.tar.gz", hash = "sha256:3093a8ab8980d9f910b9cb4351df9186a0ad2350a6284a9107ac9a362a584422", size = 64618, upload-time = "2026-06-11T06:35:53.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/72/4fdf2306143a92a471fad9f3655aa542d43aa9188a7c9534e82c9aecf837/httpcore2-2.4.0-py3-none-any.whl", hash = "sha256:5218779da5d6e3c2013ac706121abfb3815d450e0613495c0de50264dce58242", size = 80151, upload-time = "2026-06-11T06:35:50.89Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -333,18 +346,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/60/b43ced4ccf26e95b396dbf67051d3e5042b645917d4da0469dd82a3bdd4f/httpx2-2.4.0.tar.gz", hash = "sha256:32e0734b61eb0824b3f56a9e98d6d92d381a3ef12c0045aa917ee63df6c411ef", size = 81691, upload-time = "2026-06-11T06:35:54.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/45/82bc57c3d9c3314f663b67cc057f1c017a6450685dde513f4f8db5cf431f/httpx2-2.4.0-py3-none-any.whl", hash = "sha256:425acd99297829599decf6701386dd84db3542597d36d3e2e4def930ecd57fd9", size = 74941, upload-time = "2026-06-11T06:35:52.235Z" }, +] + [[package]] name = "hyperping" -version = "1.7.0" +version = "1.8.0" source = { editable = "." } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pydantic" }, ] [package.optional-dependencies] dev = [ { name = "mypy" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "pip-audit" }, { name = "pydantic" }, { name = "pytest" }, @@ -352,6 +383,9 @@ dev = [ { name = "respx" }, { name = "ruff" }, ] +otel = [ + { name = "opentelemetry-api" }, +] [package.dev-dependencies] dev = [ @@ -360,8 +394,11 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "httpx2", specifier = ">=2.4,<3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "opentelemetry-api", marker = "extra == 'dev'", specifier = ">=1.20" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, + { name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.20" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, { name = "pydantic", marker = "extra == 'dev'" }, @@ -370,18 +407,18 @@ requires-dist = [ { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, ] -provides-extras = ["dev"] +provides-extras = ["otel", "dev"] [package.metadata.requires-dev] dev = [{ name = "pytest-asyncio", specifier = ">=0.23.0" }] [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -600,6 +637,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -1022,6 +1098,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 5c39fbbc55f33b051689196f6c42d33f9d3b0297 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 23:21:12 +0300 Subject: [PATCH 2/6] feat(otel): add OTel span instrumentation to HyperpingClient and McpTransport Add _otel.py with get_tracer, start_request_span, start_rpc_span, and record_error helpers. Each client and transport stores self._tracer at construction time. The _request loop (sync and async) is wrapped in a start_request_span context; call_tool (sync and async) is wrapped in a start_rpc_span context. Errors are recorded with record_error on the active span before re-raising. All helpers are no-ops when opentelemetry-api is not installed, so install hyperping[otel] to enable tracing. Closes #6f36bd (PY-11) --- src/hyperping/_otel.py | 97 +++++++++ tests/unit/test_otel.py | 431 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 src/hyperping/_otel.py create mode 100644 tests/unit/test_otel.py diff --git a/src/hyperping/_otel.py b/src/hyperping/_otel.py new file mode 100644 index 0000000..59d858e --- /dev/null +++ b/src/hyperping/_otel.py @@ -0,0 +1,97 @@ +"""Optional OpenTelemetry instrumentation for the Hyperping SDK. + +When opentelemetry-api is not installed, all functions are no-ops and the SDK +behaves identically to today with zero overhead. + +Install ``hyperping[otel]`` to enable tracing. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Generator +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +try: + from opentelemetry import trace + from opentelemetry.trace import Span, Status, StatusCode, Tracer # noqa: F401 + + HAS_OTEL: bool = True +except ImportError: + HAS_OTEL = False + +if TYPE_CHECKING: + from opentelemetry.trace import Span, Tracer + +from hyperping._version import __version__ + + +def get_tracer() -> Tracer | None: + """Return a Tracer for the hyperping SDK, or None when OTel is not installed.""" + if not HAS_OTEL: + return None + return trace.get_tracer("hyperping", __version__) + + +@contextlib.contextmanager +def start_request_span( + tracer: Tracer | None, + method: str, + path: str, + base_url: str, +) -> Generator[Span | None, None, None]: + """Context manager wrapping a REST API call in an OTel span. + + Yields None (no-op) when tracer is None. Compatible with both sync and + async callers because the span lifecycle is synchronous regardless. + """ + if tracer is None: + yield None + return + + span_name = f"hyperping {method} {path}" + server_address = urlsplit(base_url).hostname or base_url + + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("http.request.method", method) + span.set_attribute("url.full", base_url.rstrip("/") + path) + span.set_attribute("server.address", server_address) + span.set_attribute("hyperping.sdk.version", __version__) + yield span + + +@contextlib.contextmanager +def start_rpc_span( + tracer: Tracer | None, + rpc_method: str, + url: str, +) -> Generator[Span | None, None, None]: + """Context manager wrapping an MCP JSON-RPC call in an OTel span. + + Yields None (no-op) when tracer is None. + """ + if tracer is None: + yield None + return + + span_name = f"hyperping.mcp {rpc_method}" + server_address = urlsplit(url).hostname or url + + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("rpc.method", rpc_method) + span.set_attribute("rpc.system", "jsonrpc") + span.set_attribute("server.address", server_address) + span.set_attribute("hyperping.sdk.version", __version__) + yield span + + +def record_error(span: Span | None, exception: BaseException) -> None: + """Record an exception on a span and set its status to ERROR. + + No-op when span is None. + """ + if span is None or not HAS_OTEL: + return + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(exception) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py new file mode 100644 index 0000000..ad98569 --- /dev/null +++ b/tests/unit/test_otel.py @@ -0,0 +1,431 @@ +"""Tests for OpenTelemetry instrumentation in the Hyperping SDK.""" + +from __future__ import annotations + +import json as _json +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +import respx +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import StatusCode + +from hyperping._async_client import AsyncHyperpingClient +from hyperping._async_mcp_transport import AsyncMcpTransport +from hyperping._mcp_transport import McpTransport +from hyperping._version import __version__ as _SDK_VERSION # noqa: N812 +from hyperping.client import HyperpingClient, RetryConfig +from hyperping.endpoints import API_BASE, MCP_URL, Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _tool_resp(data: dict[str, Any], *, req_id: int = 2) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": _json.dumps(data)}]}, + }, + ) + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def _span_provider() -> Generator[tuple[InMemorySpanExporter, TracerProvider], None, None]: + """Create an isolated local TracerProvider and InMemorySpanExporter. + + Does NOT modify the global OTel TracerProvider (which can only be set once + per process). Each test gets its own provider and exporter. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + yield exporter, provider + exporter.clear() + + +@pytest.fixture +def span_exporter( + _span_provider: tuple[InMemorySpanExporter, TracerProvider], +) -> InMemorySpanExporter: + """Expose just the InMemorySpanExporter from the local provider.""" + exporter, _ = _span_provider + return exporter + + +@pytest.fixture +def otel_tracer( + _span_provider: tuple[InMemorySpanExporter, TracerProvider], +) -> Any: + """Return a test-local tracer from the isolated TracerProvider.""" + _, provider = _span_provider + return provider.get_tracer("hyperping", _SDK_VERSION) + + +@pytest.fixture +def otel_client(otel_tracer: Any) -> Generator[HyperpingClient, None, None]: + """HyperpingClient with the test-local tracer injected (bypasses global OTel).""" + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = otel_tracer # type: ignore[assignment] + yield c + c.close() + + +@pytest.fixture +def otel_async_client(otel_tracer: Any) -> Generator[AsyncHyperpingClient, None, None]: + """AsyncHyperpingClient with the test-local tracer injected.""" + c = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = otel_tracer # type: ignore[assignment] + yield c + + +@pytest.fixture +def otel_mcp(otel_tracer: Any) -> Generator[McpTransport, None, None]: + """McpTransport with test-local tracer and pre-initialized state.""" + t = McpTransport(api_key="sk_test", base_url=MCP_URL) + t._tracer = otel_tracer # type: ignore[assignment] + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + yield t + t.close() + + +@pytest.fixture +def otel_async_mcp(otel_tracer: Any) -> Generator[AsyncMcpTransport, None, None]: + """AsyncMcpTransport with test-local tracer and pre-initialized state.""" + t = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + t._tracer = otel_tracer # type: ignore[assignment] + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + yield t + + +# ── TestOtelModule ───────────────────────────────────────────────────────────── + + +class TestOtelModule: + def test_has_otel_flag_true_when_installed(self) -> None: + """HAS_OTEL is True in the test environment (opentelemetry-api is installed).""" + from hyperping._otel import HAS_OTEL + + assert HAS_OTEL is True + + def test_get_tracer_returns_tracer(self) -> None: + """get_tracer() returns a non-None tracer when OTel is installed.""" + from hyperping._otel import get_tracer + + t = get_tracer() + assert t is not None + + def test_get_tracer_returns_none_when_missing(self) -> None: + """get_tracer() returns None when HAS_OTEL is False.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + t = otel_mod.get_tracer() + assert t is None + + +# ── TestSyncClientSpans ──────────────────────────────────────────────────────── + + +class TestSyncClientSpans: + @respx.mock + def test_request_creates_span( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """GET /v1/monitors emits a span named 'hyperping GET /v1/monitors'.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = span_exporter.get_finished_spans() + assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) + + @respx.mock + def test_span_has_http_method_attribute( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span carries http.request.method = 'GET'.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans, "No matching span found" + assert spans[0].attributes.get("http.request.method") == "GET" # type: ignore[union-attr] + + @respx.mock + def test_span_has_url_attribute( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span carries url.full.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert "url.full" in (spans[0].attributes or {}) + + @respx.mock + def test_span_has_status_ok_on_success( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is UNSET (interpreted as OK) on successful 200 response.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.UNSET + + @respx.mock + def test_span_records_error_on_api_error( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is ERROR and exception is recorded on 500 response.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(500, json={"error": "Internal Server Error"}) + ) + with pytest.raises(HyperpingAPIError): + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_span_records_error_on_auth_error( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is ERROR on 401 (authentication failure).""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(401, json={"error": "Unauthorized"}) + ) + with pytest.raises(HyperpingAuthError): + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_no_span_when_tracer_is_none( + self, span_exporter: InMemorySpanExporter + ) -> None: + """When _tracer is None, no span is emitted and the client works normally.""" + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = None # type: ignore[assignment] + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + c.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "hyperping" in s.name] + assert len(spans) == 0 + c.close() + + +# ── TestAsyncClientSpans ─────────────────────────────────────────────────────── + + +class TestAsyncClientSpans: + @respx.mock + async def test_async_request_creates_span( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async GET /v1/monitors emits a span.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + await otel_async_client.list_monitors() + spans = span_exporter.get_finished_spans() + assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) + await otel_async_client.close() + + @respx.mock + async def test_async_span_has_attributes( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async span carries http.request.method and url.full.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + await otel_async_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + attrs = spans[0].attributes or {} + assert attrs.get("http.request.method") == "GET" + assert "url.full" in attrs + await otel_async_client.close() + + @respx.mock + async def test_async_span_records_error( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async span status is ERROR on 500.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(500, json={"error": "Server Error"}) + ) + with pytest.raises(HyperpingAPIError): + await otel_async_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + await otel_async_client.close() + + +# ── TestMcpTransportSpans ────────────────────────────────────────────────────── + + +class TestMcpTransportSpans: + @respx.mock + def test_mcp_rpc_creates_span( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """tools/call emits a span named 'hyperping.mcp tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = span_exporter.get_finished_spans() + assert any(s.name == "hyperping.mcp tools/call" for s in spans) + + @respx.mock + def test_mcp_span_has_rpc_method_attribute( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span carries rpc.method = 'tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].attributes.get("rpc.method") == "tools/call" # type: ignore[union-attr] + + @respx.mock + def test_mcp_span_has_rpc_system_attribute( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span carries rpc.system = 'jsonrpc'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].attributes.get("rpc.system") == "jsonrpc" # type: ignore[union-attr] + + @respx.mock + def test_mcp_span_records_error_on_rpc_error( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span status is ERROR when JSON-RPC returns an error.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": 2, "error": {"code": -32600, "message": "Bad req"}}, + ) + ) + with pytest.raises(HyperpingAPIError): + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_mcp_span_records_error_on_http_error( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span status is ERROR on HTTP 500.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(500)) + with pytest.raises(HyperpingAPIError): + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + +# ── TestAsyncMcpTransportSpans ───────────────────────────────────────────────── + + +class TestAsyncMcpTransportSpans: + @respx.mock + async def test_async_mcp_rpc_creates_span( + self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """Async tools/call emits 'hyperping.mcp tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + await otel_async_mcp.call_tool("test_tool") + spans = span_exporter.get_finished_spans() + assert any(s.name == "hyperping.mcp tools/call" for s in spans) + await otel_async_mcp.close() + + @respx.mock + async def test_async_mcp_span_records_error( + self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """Async MCP span status is ERROR on HTTP 500.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(500)) + with pytest.raises(HyperpingAPIError): + await otel_async_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + await otel_async_mcp.close() + + +# ── TestNoopWhenUninstalled ──────────────────────────────────────────────────── + + +class TestNoopWhenUninstalled: + @respx.mock + def test_sync_client_works_without_otel(self) -> None: + """Client works normally when _tracer is None (simulates HAS_OTEL=False).""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + assert c._tracer is None + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + result = c.list_monitors() + assert result == [] + c.close() + + @respx.mock + async def test_async_client_works_without_otel(self) -> None: + """Async client works normally when _tracer is None.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + c = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + assert c._tracer is None + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + result = await c.list_monitors() + assert result == [] + await c.close() + + @respx.mock + def test_mcp_transport_works_without_otel(self) -> None: + """MCP transport works normally when _tracer is None.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + t = McpTransport(api_key="sk_test", base_url=MCP_URL) + assert t._tracer is None + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + result = t.call_tool("test_tool") + assert result == {"ok": True} + t.close() From fda4d1998940f6c5e3265eb448651fb82182c54c Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 23:31:37 +0300 Subject: [PATCH 3/6] style: apply ruff format to pre-existing format violations Several files introduced in earlier commits did not pass ruff format --check. Format them so the full src/ + tests/ tree is consistently formatted. Co-Authored-By: Khaled Salhab --- src/hyperping/_internals.py | 15 ++---- tests/unit/test_async_mcp_client.py | 4 +- tests/unit/test_async_mcp_transport.py | 46 +++++++------------ tests/unit/test_mcp_client.py | 8 ++-- tests/unit/test_mcp_transport.py | 46 +++++++------------ tests/unit/test_otel.py | 4 +- tests/unit/test_security_base_url.py | 5 +- .../unit/test_security_exception_redaction.py | 3 +- 8 files changed, 49 insertions(+), 82 deletions(-) diff --git a/src/hyperping/_internals.py b/src/hyperping/_internals.py index a32e4ab..c1356cf 100644 --- a/src/hyperping/_internals.py +++ b/src/hyperping/_internals.py @@ -83,20 +83,11 @@ def validate_base_url( # (``https://@host``, ``https://:@host``). ``parts.username`` is an empty # string in those cases, so the previous ``or`` truthiness guard let them # through. Checking the raw authority for ``@`` is exhaustive. - if ( - "@" in parts.netloc - or parts.username is not None - or parts.password is not None - ): - raise ValueError( - f"{param_name} must not embed userinfo (credentials) in the URL" - ) + if "@" in parts.netloc or parts.username is not None or parts.password is not None: + raise ValueError(f"{param_name} must not embed userinfo (credentials) in the URL") if parts.query or parts.fragment: - raise ValueError( - f"{param_name} must not carry a query string or fragment " - f"(got {url!r})" - ) + raise ValueError(f"{param_name} must not carry a query string or fragment (got {url!r})") if parts.scheme == "http": if not allow_insecure: diff --git a/tests/unit/test_async_mcp_client.py b/tests/unit/test_async_mcp_client.py index 3c76a2d..afaac66 100644 --- a/tests/unit/test_async_mcp_client.py +++ b/tests/unit/test_async_mcp_client.py @@ -302,7 +302,9 @@ async def test_ensure_initialized_delegates_to_transport(): async def test_ensure_initialized_propagates_rate_limit(): client = make_client() client._transport.initialize.side_effect = HyperpingRateLimitError( - "rate limited on initialize", retry_after=30, status_code=200, + "rate limited on initialize", + retry_after=30, + status_code=200, ) with pytest.raises(HyperpingRateLimitError) as exc_info: await client.ensure_initialized() diff --git a/tests/unit/test_async_mcp_transport.py b/tests/unit/test_async_mcp_transport.py index 55124aa..6b28eec 100644 --- a/tests/unit/test_async_mcp_transport.py +++ b/tests/unit/test_async_mcp_transport.py @@ -458,9 +458,7 @@ async def test_initialize_is_idempotent(): async def test_initialize_rate_limit_latches_cooloff(monkeypatch): """After a rate-limited initialize, further call_tool calls short-circuit.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_response = httpx.Response( 200, @@ -494,9 +492,7 @@ async def test_initialize_rate_limit_latches_cooloff(monkeypatch): async def test_initialize_cooloff_clears_after_deadline(monkeypatch): """Once the cool-off elapses, async initialize is attempted again.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_body = { "jsonrpc": "2.0", @@ -542,7 +538,9 @@ async def test_rate_limit_is_not_retried_by_call_tool(): """call_tool's transient retry loop must NOT retry HyperpingRateLimitError.""" route = respx.post(MCP_URL).mock( return_value=httpx.Response( - 429, text="Rate limited", headers={"retry-after": "5"}, + 429, + text="Rate limited", + headers={"retry-after": "5"}, ), ) transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=3) @@ -583,14 +581,14 @@ async def test_jsonrpc_rate_limit_is_not_retried_by_call_tool(): @pytest.mark.parametrize( "message, expected", [ - ('Hyperping MCP rate limit exceeded. Retry after 32s.', 32), + ("Hyperping MCP rate limit exceeded. Retry after 32s.", 32), ('Rate limit exceeded for "initialize". Retry-After: 30s', 30), - ('Rate limit exceeded. retry after 30 seconds.', 30), - ('Rate limit exceeded. RETRY AFTER 7', 7), - ('Rate limit exceeded. Retry after 0s', 0), - ('Rate limit exceeded. Retry after 1.5s', 1), - ('Rate limit exceeded.', None), - ('Rate limit exceeded. Try again later.', None), + ("Rate limit exceeded. retry after 30 seconds.", 30), + ("Rate limit exceeded. RETRY AFTER 7", 7), + ("Rate limit exceeded. Retry after 0s", 0), + ("Rate limit exceeded. Retry after 1.5s", 1), + ("Rate limit exceeded.", None), + ("Rate limit exceeded. Try again later.", None), ], ) @respx.mock @@ -643,9 +641,7 @@ async def test_jsonrpc_rate_limit_marker_requires_exceeded(): @respx.mock async def test_notifications_initialized_rate_limit_classified(monkeypatch): """A 200 + -32000 on notifications/initialized raises HyperpingRateLimitError.""" - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: 1000.0 - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: 1000.0) respx.post(MCP_URL).mock( side_effect=[ INIT_RESPONSE, @@ -677,9 +673,7 @@ async def test_notifications_initialized_rate_limit_classified(monkeypatch): async def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): """A latch armed by HTTP 429 must short-circuit with status_code=429.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 429, @@ -701,9 +695,7 @@ async def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): async def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): """A latch armed by JSON-RPC -32000 short-circuits with status_code=200.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -734,9 +726,7 @@ async def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): async def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): """When server advertises Retry-After: 30, short-circuit returns 30, not 31.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -776,9 +766,7 @@ async def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): async def test_jsonrpc_rate_limit_with_retry_after_zero_does_not_latch(monkeypatch): """retry_after=0 from server means retry-now; do not set a 30s default.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( side_effect=[ httpx.Response( diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index b55d608..b847c1a 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -278,7 +278,9 @@ def test_ensure_initialized_delegates_to_transport(): def test_ensure_initialized_propagates_rate_limit(): client = make_client() client._transport.initialize.side_effect = HyperpingRateLimitError( - "rate limited on initialize", retry_after=30, status_code=200, + "rate limited on initialize", + retry_after=30, + status_code=200, ) with pytest.raises(HyperpingRateLimitError) as exc_info: client.ensure_initialized() @@ -331,6 +333,4 @@ def test_changelog_documents_mcp_rate_limit_work(): assert "ensure_initialized" in changelog, ( "CHANGELOG must mention ensure_initialized() somewhere" ) - assert "rate limit" in changelog.lower(), ( - "CHANGELOG must mention rate-limit handling somewhere" - ) + assert "rate limit" in changelog.lower(), "CHANGELOG must mention rate-limit handling somewhere" diff --git a/tests/unit/test_mcp_transport.py b/tests/unit/test_mcp_transport.py index 7a0bc59..8659994 100644 --- a/tests/unit/test_mcp_transport.py +++ b/tests/unit/test_mcp_transport.py @@ -539,9 +539,7 @@ def test_initialize_is_idempotent(): def test_initialize_rate_limit_latches_cooloff(monkeypatch): """After a rate-limited initialize, further call_tool calls short-circuit.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_response = httpx.Response( 200, @@ -576,9 +574,7 @@ def test_initialize_rate_limit_latches_cooloff(monkeypatch): def test_initialize_cooloff_clears_after_deadline(monkeypatch): """Once the cool-off elapses, initialize is attempted again.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_body = { "jsonrpc": "2.0", @@ -638,7 +634,9 @@ def test_rate_limit_is_not_retried_by_call_tool(): """call_tool's transient retry loop must NOT retry HyperpingRateLimitError.""" route = respx.post(MCP_URL).mock( return_value=httpx.Response( - 429, text="Rate limited", headers={"retry-after": "5"}, + 429, + text="Rate limited", + headers={"retry-after": "5"}, ), ) transport = McpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=3) @@ -714,14 +712,14 @@ def test_six_fresh_clients_under_jsonrpc_rate_limit_all_fail_typed(): @pytest.mark.parametrize( "message, expected", [ - ('Hyperping MCP rate limit exceeded. Retry after 32s.', 32), + ("Hyperping MCP rate limit exceeded. Retry after 32s.", 32), ('Rate limit exceeded for "initialize". Retry-After: 30s', 30), - ('Rate limit exceeded. retry after 30 seconds.', 30), - ('Rate limit exceeded. RETRY AFTER 7', 7), - ('Rate limit exceeded. Retry after 0s', 0), - ('Rate limit exceeded. Retry after 1.5s', 1), # sub-second floored - ('Rate limit exceeded.', None), # no advertised value - ('Rate limit exceeded. Try again later.', None), # graceful + ("Rate limit exceeded. retry after 30 seconds.", 30), + ("Rate limit exceeded. RETRY AFTER 7", 7), + ("Rate limit exceeded. Retry after 0s", 0), + ("Rate limit exceeded. Retry after 1.5s", 1), # sub-second floored + ("Rate limit exceeded.", None), # no advertised value + ("Rate limit exceeded. Try again later.", None), # graceful ], ) @respx.mock @@ -777,9 +775,7 @@ def test_jsonrpc_rate_limit_marker_requires_exceeded(): @respx.mock def test_notifications_initialized_rate_limit_classified(monkeypatch): """A 200 + -32000 on notifications/initialized raises HyperpingRateLimitError.""" - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: 1000.0 - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: 1000.0) respx.post(MCP_URL).mock( side_effect=[ # initialize succeeds @@ -818,9 +814,7 @@ def test_notifications_initialized_rate_limit_classified(monkeypatch): def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): """A latch armed by HTTP 429 must short-circuit with status_code=429.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 429, @@ -843,9 +837,7 @@ def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): """A latch armed by JSON-RPC -32000 short-circuits with status_code=200.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -876,9 +868,7 @@ def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): """When server advertises Retry-After: 30, short-circuit returns 30, not 31.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -921,9 +911,7 @@ def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): def test_jsonrpc_rate_limit_with_retry_after_zero_does_not_latch(monkeypatch): """retry_after=0 from server means retry-now; do not set a 30s default.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) ok_init = httpx.Response( 200, json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26"}}, diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index ad98569..0f5a28f 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -220,9 +220,7 @@ def test_span_records_error_on_auth_error( assert spans[0].status.status_code == StatusCode.ERROR @respx.mock - def test_no_span_when_tracer_is_none( - self, span_exporter: InMemorySpanExporter - ) -> None: + def test_no_span_when_tracer_is_none(self, span_exporter: InMemorySpanExporter) -> None: """When _tracer is None, no span is emitted and the client works normally.""" c = HyperpingClient( api_key="sk_test_key", diff --git a/tests/unit/test_security_base_url.py b/tests/unit/test_security_base_url.py index 92263a1..7de0234 100644 --- a/tests/unit/test_security_base_url.py +++ b/tests/unit/test_security_base_url.py @@ -67,8 +67,9 @@ def test_http_allowed_with_explicit_opt_in_emits_warning(ctor): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") client = ctor("http://localhost:8080", allow_insecure=True) - assert any("insecure" in str(w.message).lower() or "http" in str(w.message).lower() - for w in caught), "expected a security warning when allow_insecure=True" + assert any( + "insecure" in str(w.message).lower() or "http" in str(w.message).lower() for w in caught + ), "expected a security warning when allow_insecure=True" # Clean up where applicable. closer = getattr(client, "close", None) if closer is not None and not callable(getattr(closer, "__await__", None)): diff --git a/tests/unit/test_security_exception_redaction.py b/tests/unit/test_security_exception_redaction.py index 1492946..41d27cd 100644 --- a/tests/unit/test_security_exception_redaction.py +++ b/tests/unit/test_security_exception_redaction.py @@ -144,8 +144,7 @@ def test_mcp_transport_5xx_does_not_leak_raw_server_body() -> None: respx.post(MCP_URL).mock( return_value=httpx.Response( 500, - text='{"echo":{"subscriber_email":"victim@example.com"},' - '"hint":"Bearer sk_secret_mcp"}', + text='{"echo":{"subscriber_email":"victim@example.com"},"hint":"Bearer sk_secret_mcp"}', ) ) transport = McpTransport(api_key="sk_test", base_url=MCP_URL) From 9d665b7b2398714e5b73086c2e21cdd5477f579f Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 23:31:43 +0300 Subject: [PATCH 4/6] feat(streaming): add Alert model and AsyncStreamingMixin (PY-10) Add poll-based async streaming helpers for alert and incident monitoring. Introduce Alert/AlertType provisional models derived from the monitors endpoint, and AsyncStreamingMixin with stream_alerts and stream_incident_updates. Wire the mixin into AsyncHyperpingClient and export Alert/AlertType from the public API. Rate-limit note: default 30s interval uses 2 req/min per stream_alerts call, approximately 0.67% of the 300 req/min account limit. Co-Authored-By: Khaled Salhab --- src/hyperping/__init__.py | 5 ++ src/hyperping/_async_client.py | 2 + src/hyperping/_async_streaming_mixin.py | 113 ++++++++++++++++++++++++ src/hyperping/models/__init__.py | 4 + src/hyperping/models/_alert_models.py | 36 ++++++++ 5 files changed, 160 insertions(+) create mode 100644 src/hyperping/_async_streaming_mixin.py create mode 100644 src/hyperping/models/_alert_models.py diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 25dab8b..adf460b 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -41,7 +41,9 @@ from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, + Alert, AlertHistory, + AlertType, DnsRecordType, EscalationPolicy, Healthcheck, @@ -120,6 +122,9 @@ "HyperpingNotFoundError", "HyperpingRateLimitError", "HyperpingValidationError", + # Alert models (PY-10: provisional, reconcile when alerts endpoint ships) + "Alert", + "AlertType", # Monitor enums "HttpMethod", "MonitorFrequency", diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 63dca10..6cf128d 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -29,6 +29,7 @@ from hyperping._async_monitors_mixin import AsyncMonitorsMixin from hyperping._async_outages_mixin import AsyncOutagesMixin from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin +from hyperping._async_streaming_mixin import AsyncStreamingMixin from hyperping._circuit_breaker import ( CircuitBreaker, CircuitBreakerConfig, @@ -58,6 +59,7 @@ class AsyncHyperpingClient( AsyncOutagesMixin, AsyncStatusPagesMixin, AsyncHealthchecksMixin, + AsyncStreamingMixin, ): """Async client for interacting with the Hyperping API. diff --git a/src/hyperping/_async_streaming_mixin.py b/src/hyperping/_async_streaming_mixin.py new file mode 100644 index 0000000..476115b --- /dev/null +++ b/src/hyperping/_async_streaming_mixin.py @@ -0,0 +1,113 @@ +"""Async streaming helpers for event-driven integrations (PY-10). + +Provides poll-based AsyncIterator helpers on top of existing REST endpoints. +Public signatures are stable; only the poll internals change when Hyperping +ships SSE or a discrete alerts endpoint. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import parse_list, unwrap_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models import IncidentUpdate, Monitor +from hyperping.models._alert_models import Alert, AlertType + +if TYPE_CHECKING: + from hyperping.models import Incident + +logger = logging.getLogger(__name__) + + +class AsyncStreamingMixin(_AsyncClientProtocol): + """Poll-based streaming helpers for alert and incident monitoring.""" + + if TYPE_CHECKING: + + async def get_incident(self, incident_id: str) -> Incident: ... + + async def stream_alerts(self, *, poll_interval: float = 30.0) -> AsyncIterator[Alert]: + """Stream monitor state-transition events. + + Polls ``GET /v1/monitors`` at *poll_interval* seconds. Yields an + :class:`~hyperping.models.Alert` on each up/down state change. + The first poll establishes the baseline and yields nothing. + + Rate-limit note: at the default 30-second interval this uses + 2 requests/min, roughly 0.67% of the 300 req/min account limit. + + Args: + poll_interval: Seconds between polls. Defaults to 30.0. + + Yields: + :class:`~hyperping.models.Alert` on each monitor state transition. + + Note: + This implementation is provisional. The ``Alert`` model fields are + inferred from the monitors list endpoint. When Hyperping ships a + dedicated alerts endpoint, the model will be reconciled and the + poll internals replaced with SSE or long-poll without any change + to this method's signature. + """ + baseline: dict[str, bool] = {} + + while True: + response = await self._request("GET", Endpoint.MONITORS) + monitors = parse_list(unwrap_list(response, "monitors"), Monitor, "monitor") + + for monitor in monitors: + uuid = monitor.uuid + down = monitor.down + + if uuid in baseline and baseline[uuid] != down: + alert_type = AlertType.DOWN if down else AlertType.UP + yield Alert( + monitor_uuid=uuid, + monitor_name=monitor.name, + type=alert_type, + timestamp=datetime.now(UTC).isoformat(), + ) + + baseline[uuid] = down + + await asyncio.sleep(poll_interval) + + async def stream_incident_updates( + self, incident_uuid: str, *, poll_interval: float = 30.0 + ) -> AsyncIterator[IncidentUpdate]: + """Stream new updates for an incident. + + Polls ``GET /v3/incidents/{uuid}`` at *poll_interval* seconds. Yields + each :class:`~hyperping.models.IncidentUpdate` exactly once, deduped + by update UUID. All updates present on the first poll are yielded + immediately; only new updates are yielded on subsequent polls. + + Args: + incident_uuid: UUID of the incident to watch. + poll_interval: Seconds between polls. Defaults to 30.0. + + Yields: + :class:`~hyperping.models.IncidentUpdate` for each new update. + + Raises: + ValueError: If *incident_uuid* contains unsafe characters. + HyperpingNotFoundError: If the incident does not exist (first poll). + """ + validate_id(incident_uuid, "incident_uuid") + seen: set[str] = set() + + while True: + incident = await self.get_incident(incident_uuid) + + for update in incident.updates: + if update.uuid not in seen: + seen.add(update.uuid) + yield update + + await asyncio.sleep(poll_interval) diff --git a/src/hyperping/models/__init__.py b/src/hyperping/models/__init__.py index d693bf3..c3b50d8 100644 --- a/src/hyperping/models/__init__.py +++ b/src/hyperping/models/__init__.py @@ -9,6 +9,7 @@ be removed in v0.3.0. """ +from hyperping.models._alert_models import Alert, AlertType from hyperping.models._healthcheck_models import ( Healthcheck, HealthcheckCreate, @@ -81,6 +82,9 @@ ) __all__ = [ + # Alert models (PY-10: provisional, reconcile when alerts endpoint ships) + "Alert", + "AlertType", # Shared primitives "LocalizedText", "RequestHeader", diff --git a/src/hyperping/models/_alert_models.py b/src/hyperping/models/_alert_models.py new file mode 100644 index 0000000..219add4 --- /dev/null +++ b/src/hyperping/models/_alert_models.py @@ -0,0 +1,36 @@ +"""Alert models for streaming helpers (PY-10). + +This module is provisional. The Alert model fields are derived from the monitors +list endpoint. When Hyperping ships a discrete alerts endpoint, the model will +be reconciled with the real API shape. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + + +class AlertType(StrEnum): + """Alert transition type.""" + + DOWN = "down" + UP = "up" + DEGRADED = "degraded" + + +class Alert(BaseModel): + """A monitor state-transition event yielded by stream_alerts. + + Provisional model: fields are inferred from the monitors list endpoint. + When Hyperping ships a dedicated alerts endpoint, this model will be + reconciled against the real API shape. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + monitor_uuid: str = Field(..., alias="monitorUuid") + monitor_name: str = Field(..., alias="monitorName") + type: AlertType = Field(...) + timestamp: str = Field(...) From d3f964c8253a47d2e3cc1fb3fae2636e304c48b9 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 23:31:48 +0300 Subject: [PATCH 5/6] test(streaming): add stream_alerts and stream_incident_updates tests (PY-10) 17 tests covering the Alert model (frozen, extra fields, aliases, enum values) and both streaming helpers (first-poll baseline, state transitions, dedup, invalid UUID, not-found propagation, and poll_interval forwarding). Co-Authored-By: Khaled Salhab --- tests/unit/test_streaming.py | 474 +++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 tests/unit/test_streaming.py diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py new file mode 100644 index 0000000..96f39dd --- /dev/null +++ b/tests/unit/test_streaming.py @@ -0,0 +1,474 @@ +"""Tests for async streaming helpers (PY-10).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models import Incident, IncidentUpdate, LocalizedText +from hyperping.models._alert_models import Alert, AlertType + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _monitor_dict(uuid: str, name: str, *, down: bool) -> dict: + return { + "uuid": uuid, + "name": name, + "url": "https://example.com", + "protocol": "http", + "down": down, + "paused": False, + } + + +def _incident(uuid: str, updates: list[dict]) -> Incident: + return Incident( + uuid=uuid, + title=LocalizedText(en="Test Incident"), + type="incident", + statuspages=["sp_1"], + updates=[ + IncidentUpdate( + uuid=u["uuid"], + date=u["date"], + text=LocalizedText(en=u["text"]), + type=u["type"], + ) + for u in updates + ], + ) + + +async def _drain(gen, *, until_cancelled: bool = False) -> list: + """Collect items from an async generator, catching CancelledError.""" + items: list = [] + try: + async for item in gen: + items.append(item) + except asyncio.CancelledError: + if not until_cancelled: + raise + return items + + +@pytest_asyncio.fixture +async def async_client() -> AsyncHyperpingClient: + client = AsyncHyperpingClient( + api_key="sk_test_key", + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# --------------------------------------------------------------------------- +# Alert model tests +# --------------------------------------------------------------------------- + + +class TestAlertModel: + def test_alert_model_frozen(self) -> None: + a = Alert( + monitor_uuid="mon_1", + monitor_name="Test", + type=AlertType.DOWN, + timestamp="2026-01-01T00:00:00+00:00", + ) + with pytest.raises(Exception): + a.monitor_uuid = "mon_2" # type: ignore[misc] + + def test_alert_model_extra_fields_allowed(self) -> None: + a = Alert( + monitor_uuid="mon_1", + monitor_name="Test", + type=AlertType.DOWN, + timestamp="2026-01-01T00:00:00+00:00", + extra_field="ignored", + ) + assert a.monitor_uuid == "mon_1" + + def test_alert_type_enum_values(self) -> None: + assert AlertType.DOWN == "down" + assert AlertType.UP == "up" + assert AlertType.DEGRADED == "degraded" + + def test_alert_accepts_alias_names(self) -> None: + a = Alert( + monitorUuid="mon_alias", + monitorName="Alias Test", + type=AlertType.UP, + timestamp="2026-01-01T00:00:00+00:00", + ) + assert a.monitor_uuid == "mon_alias" + assert a.monitor_name == "Alias Test" + + +# --------------------------------------------------------------------------- +# stream_alerts tests +# --------------------------------------------------------------------------- + + +class TestStreamAlerts: + async def test_stream_alerts_no_alert_on_first_poll( + self, async_client: AsyncHyperpingClient + ) -> None: + """First poll establishes baseline; nothing yielded.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert results == [] + + async def test_stream_alerts_yields_down_alert_on_transition( + self, async_client: AsyncHyperpingClient + ) -> None: + """Monitor flips from up to down; yields Alert(type='down').""" + poll1 = [_monitor_dict("mon_1", "Test", down=False)] + poll2 = [_monitor_dict("mon_1", "Test", down=True)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 1 + assert isinstance(results[0], Alert) + assert results[0].type == AlertType.DOWN + assert results[0].monitor_uuid == "mon_1" + assert results[0].monitor_name == "Test" + + async def test_stream_alerts_yields_up_alert_on_recovery( + self, async_client: AsyncHyperpingClient + ) -> None: + """Monitor recovers from down to up; yields Alert(type='up').""" + poll1 = [_monitor_dict("mon_1", "Test", down=True)] + poll2 = [_monitor_dict("mon_1", "Test", down=False)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 1 + assert results[0].type == AlertType.UP + + async def test_stream_alerts_no_alert_when_state_unchanged( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two identical polls produce no alerts.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "_request", + new=AsyncMock(side_effect=[monitor_data, monitor_data]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert results == [] + + async def test_stream_alerts_multiple_monitors( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two monitors each flip independently; one alert per transition.""" + poll1 = [ + _monitor_dict("mon_1", "Alpha", down=False), + _monitor_dict("mon_2", "Beta", down=False), + ] + poll2 = [ + _monitor_dict("mon_1", "Alpha", down=True), + _monitor_dict("mon_2", "Beta", down=True), + ] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 2 + uuids = {a.monitor_uuid for a in results} + assert uuids == {"mon_1", "mon_2"} + assert all(a.type == AlertType.DOWN for a in results) + + async def test_stream_alerts_respects_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """asyncio.sleep is called with the configured interval.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + if len(recorded_intervals) >= 1: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain(async_client.stream_alerts(poll_interval=42.0), until_cancelled=True) + + assert recorded_intervals[0] == 42.0 + + async def test_stream_alerts_custom_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """Non-default poll_interval is forwarded to asyncio.sleep.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain(async_client.stream_alerts(poll_interval=5.0), until_cancelled=True) + + assert recorded_intervals[0] == 5.0 + + +# --------------------------------------------------------------------------- +# stream_incident_updates tests +# --------------------------------------------------------------------------- + + +class TestStreamIncidentUpdates: + async def test_stream_incident_updates_yields_new_update( + self, async_client: AsyncHyperpingClient + ) -> None: + """A new update appearing between polls is yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + upd2 = { + "uuid": "upd_2", + "date": "2026-01-01T01:00:00Z", + "text": "second", + "type": "resolved", + } + + incident_v1 = _incident("inci_1", [upd1]) + incident_v2 = _incident("inci_1", [upd1, upd2]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident_v1, incident_v2]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + uuids = [r.uuid for r in results] + assert "upd_2" in uuids + + async def test_stream_incident_updates_no_yield_for_seen_updates( + self, async_client: AsyncHyperpingClient + ) -> None: + """Updates seen on a previous poll are not re-yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + incident = _incident("inci_1", [upd1]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident, incident]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + assert len([r for r in results if r.uuid == "upd_1"]) == 1 + + async def test_stream_incident_updates_multiple_new_updates( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two new updates appearing at once are both yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + upd2 = { + "uuid": "upd_2", + "date": "2026-01-01T01:00:00Z", + "text": "second", + "type": "identified", + } + upd3 = { + "uuid": "upd_3", + "date": "2026-01-01T02:00:00Z", + "text": "third", + "type": "resolved", + } + + incident_v1 = _incident("inci_1", [upd1]) + incident_v2 = _incident("inci_1", [upd1, upd2, upd3]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident_v1, incident_v2]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + new_uuids = {r.uuid for r in results} - {"upd_1"} + assert new_uuids == {"upd_2", "upd_3"} + + async def test_stream_incident_updates_invalid_uuid_raises( + self, async_client: AsyncHyperpingClient + ) -> None: + """Invalid UUID format raises ValueError before polling starts.""" + with pytest.raises(ValueError, match="Invalid"): + async for _ in async_client.stream_incident_updates("not/a/valid/id"): + pass + + async def test_stream_incident_updates_not_found_raises( + self, async_client: AsyncHyperpingClient + ) -> None: + """Incident not found raises HyperpingNotFoundError on first poll.""" + with patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=HyperpingNotFoundError("not found", status_code=404)), + ): + with pytest.raises(HyperpingNotFoundError): + async for _ in async_client.stream_incident_updates("inci_missing"): + pass + + async def test_stream_incident_updates_respects_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """asyncio.sleep is called with the configured interval.""" + incident = _incident("inci_1", []) + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(return_value=incident), + ), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=15.0), + until_cancelled=True, + ) + + assert recorded_intervals[0] == 15.0 From f984462f1a31884190d2b144b601ecd279758aef Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 14 Jun 2026 14:34:54 +0300 Subject: [PATCH 6/6] remove premature OTel scaffolding from PY-10 streaming PR _otel.py and test_otel.py were included here, but the client-side wiring the tests exercise (HyperpingClient._tracer etc.) is part of PY-11 and lives only in the OTel auto-instrumentation PR. As shipped, _otel.py was unused dead code and test_otel.py failed standalone. Remove both files and the opentelemetry deps so this PR is scoped to PY-10 async streaming and its test suite passes. OTel is delivered solely by the PY-11 PR. --- pyproject.toml | 5 - src/hyperping/_otel.py | 97 --------- tests/unit/test_otel.py | 429 ---------------------------------------- uv.lock | 88 ++++----- 4 files changed, 40 insertions(+), 579 deletions(-) delete mode 100644 src/hyperping/_otel.py delete mode 100644 tests/unit/test_otel.py diff --git a/pyproject.toml b/pyproject.toml index e4a7289..af584e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,6 @@ dependencies = [ [project.optional-dependencies] cli = ["typer[all]>=0.12,<1.0"] -otel = [ - "opentelemetry-api>=1.20", -] dev = [ "pytest>=9.0.3", "pytest-cov", @@ -44,8 +41,6 @@ dev = [ "pydantic", "pip-audit>=2.7", "typer[all]>=0.12,<1.0", - "opentelemetry-api>=1.20", - "opentelemetry-sdk>=1.20", ] [project.scripts] diff --git a/src/hyperping/_otel.py b/src/hyperping/_otel.py deleted file mode 100644 index 59d858e..0000000 --- a/src/hyperping/_otel.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Optional OpenTelemetry instrumentation for the Hyperping SDK. - -When opentelemetry-api is not installed, all functions are no-ops and the SDK -behaves identically to today with zero overhead. - -Install ``hyperping[otel]`` to enable tracing. -""" - -from __future__ import annotations - -import contextlib -from collections.abc import Generator -from typing import TYPE_CHECKING -from urllib.parse import urlsplit - -try: - from opentelemetry import trace - from opentelemetry.trace import Span, Status, StatusCode, Tracer # noqa: F401 - - HAS_OTEL: bool = True -except ImportError: - HAS_OTEL = False - -if TYPE_CHECKING: - from opentelemetry.trace import Span, Tracer - -from hyperping._version import __version__ - - -def get_tracer() -> Tracer | None: - """Return a Tracer for the hyperping SDK, or None when OTel is not installed.""" - if not HAS_OTEL: - return None - return trace.get_tracer("hyperping", __version__) - - -@contextlib.contextmanager -def start_request_span( - tracer: Tracer | None, - method: str, - path: str, - base_url: str, -) -> Generator[Span | None, None, None]: - """Context manager wrapping a REST API call in an OTel span. - - Yields None (no-op) when tracer is None. Compatible with both sync and - async callers because the span lifecycle is synchronous regardless. - """ - if tracer is None: - yield None - return - - span_name = f"hyperping {method} {path}" - server_address = urlsplit(base_url).hostname or base_url - - with tracer.start_as_current_span(span_name) as span: - span.set_attribute("http.request.method", method) - span.set_attribute("url.full", base_url.rstrip("/") + path) - span.set_attribute("server.address", server_address) - span.set_attribute("hyperping.sdk.version", __version__) - yield span - - -@contextlib.contextmanager -def start_rpc_span( - tracer: Tracer | None, - rpc_method: str, - url: str, -) -> Generator[Span | None, None, None]: - """Context manager wrapping an MCP JSON-RPC call in an OTel span. - - Yields None (no-op) when tracer is None. - """ - if tracer is None: - yield None - return - - span_name = f"hyperping.mcp {rpc_method}" - server_address = urlsplit(url).hostname or url - - with tracer.start_as_current_span(span_name) as span: - span.set_attribute("rpc.method", rpc_method) - span.set_attribute("rpc.system", "jsonrpc") - span.set_attribute("server.address", server_address) - span.set_attribute("hyperping.sdk.version", __version__) - yield span - - -def record_error(span: Span | None, exception: BaseException) -> None: - """Record an exception on a span and set its status to ERROR. - - No-op when span is None. - """ - if span is None or not HAS_OTEL: - return - span.set_status(Status(StatusCode.ERROR)) - span.record_exception(exception) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py deleted file mode 100644 index 0f5a28f..0000000 --- a/tests/unit/test_otel.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Tests for OpenTelemetry instrumentation in the Hyperping SDK.""" - -from __future__ import annotations - -import json as _json -from collections.abc import Generator -from typing import Any -from unittest.mock import patch - -import httpx -import pytest -import respx -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import StatusCode - -from hyperping._async_client import AsyncHyperpingClient -from hyperping._async_mcp_transport import AsyncMcpTransport -from hyperping._mcp_transport import McpTransport -from hyperping._version import __version__ as _SDK_VERSION # noqa: N812 -from hyperping.client import HyperpingClient, RetryConfig -from hyperping.endpoints import API_BASE, MCP_URL, Endpoint -from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError - -# ── Helpers ──────────────────────────────────────────────────────────────────── - - -def _tool_resp(data: dict[str, Any], *, req_id: int = 2) -> httpx.Response: - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": req_id, - "result": {"content": [{"type": "text", "text": _json.dumps(data)}]}, - }, - ) - - -# ── Fixtures ─────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def _span_provider() -> Generator[tuple[InMemorySpanExporter, TracerProvider], None, None]: - """Create an isolated local TracerProvider and InMemorySpanExporter. - - Does NOT modify the global OTel TracerProvider (which can only be set once - per process). Each test gets its own provider and exporter. - """ - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - yield exporter, provider - exporter.clear() - - -@pytest.fixture -def span_exporter( - _span_provider: tuple[InMemorySpanExporter, TracerProvider], -) -> InMemorySpanExporter: - """Expose just the InMemorySpanExporter from the local provider.""" - exporter, _ = _span_provider - return exporter - - -@pytest.fixture -def otel_tracer( - _span_provider: tuple[InMemorySpanExporter, TracerProvider], -) -> Any: - """Return a test-local tracer from the isolated TracerProvider.""" - _, provider = _span_provider - return provider.get_tracer("hyperping", _SDK_VERSION) - - -@pytest.fixture -def otel_client(otel_tracer: Any) -> Generator[HyperpingClient, None, None]: - """HyperpingClient with the test-local tracer injected (bypasses global OTel).""" - c = HyperpingClient( - api_key="sk_test_key", - base_url=API_BASE, - retry_config=RetryConfig(max_retries=0), - ) - c._tracer = otel_tracer # type: ignore[assignment] - yield c - c.close() - - -@pytest.fixture -def otel_async_client(otel_tracer: Any) -> Generator[AsyncHyperpingClient, None, None]: - """AsyncHyperpingClient with the test-local tracer injected.""" - c = AsyncHyperpingClient( - api_key="sk_test_key", - base_url=API_BASE, - retry_config=RetryConfig(max_retries=0), - ) - c._tracer = otel_tracer # type: ignore[assignment] - yield c - - -@pytest.fixture -def otel_mcp(otel_tracer: Any) -> Generator[McpTransport, None, None]: - """McpTransport with test-local tracer and pre-initialized state.""" - t = McpTransport(api_key="sk_test", base_url=MCP_URL) - t._tracer = otel_tracer # type: ignore[assignment] - t._initialized = True - t._init_result = {"protocolVersion": "2025-03-26"} - yield t - t.close() - - -@pytest.fixture -def otel_async_mcp(otel_tracer: Any) -> Generator[AsyncMcpTransport, None, None]: - """AsyncMcpTransport with test-local tracer and pre-initialized state.""" - t = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) - t._tracer = otel_tracer # type: ignore[assignment] - t._initialized = True - t._init_result = {"protocolVersion": "2025-03-26"} - yield t - - -# ── TestOtelModule ───────────────────────────────────────────────────────────── - - -class TestOtelModule: - def test_has_otel_flag_true_when_installed(self) -> None: - """HAS_OTEL is True in the test environment (opentelemetry-api is installed).""" - from hyperping._otel import HAS_OTEL - - assert HAS_OTEL is True - - def test_get_tracer_returns_tracer(self) -> None: - """get_tracer() returns a non-None tracer when OTel is installed.""" - from hyperping._otel import get_tracer - - t = get_tracer() - assert t is not None - - def test_get_tracer_returns_none_when_missing(self) -> None: - """get_tracer() returns None when HAS_OTEL is False.""" - import hyperping._otel as otel_mod - - with patch.object(otel_mod, "HAS_OTEL", False): - t = otel_mod.get_tracer() - assert t is None - - -# ── TestSyncClientSpans ──────────────────────────────────────────────────────── - - -class TestSyncClientSpans: - @respx.mock - def test_request_creates_span( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """GET /v1/monitors emits a span named 'hyperping GET /v1/monitors'.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - otel_client.list_monitors() - spans = span_exporter.get_finished_spans() - assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) - - @respx.mock - def test_span_has_http_method_attribute( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Span carries http.request.method = 'GET'.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - otel_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans, "No matching span found" - assert spans[0].attributes.get("http.request.method") == "GET" # type: ignore[union-attr] - - @respx.mock - def test_span_has_url_attribute( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Span carries url.full.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - otel_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - assert "url.full" in (spans[0].attributes or {}) - - @respx.mock - def test_span_has_status_ok_on_success( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Span status is UNSET (interpreted as OK) on successful 200 response.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - otel_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.UNSET - - @respx.mock - def test_span_records_error_on_api_error( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Span status is ERROR and exception is recorded on 500 response.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(500, json={"error": "Internal Server Error"}) - ) - with pytest.raises(HyperpingAPIError): - otel_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - - @respx.mock - def test_span_records_error_on_auth_error( - self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Span status is ERROR on 401 (authentication failure).""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(401, json={"error": "Unauthorized"}) - ) - with pytest.raises(HyperpingAuthError): - otel_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - - @respx.mock - def test_no_span_when_tracer_is_none(self, span_exporter: InMemorySpanExporter) -> None: - """When _tracer is None, no span is emitted and the client works normally.""" - c = HyperpingClient( - api_key="sk_test_key", - base_url=API_BASE, - retry_config=RetryConfig(max_retries=0), - ) - c._tracer = None # type: ignore[assignment] - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - c.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "hyperping" in s.name] - assert len(spans) == 0 - c.close() - - -# ── TestAsyncClientSpans ─────────────────────────────────────────────────────── - - -class TestAsyncClientSpans: - @respx.mock - async def test_async_request_creates_span( - self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Async GET /v1/monitors emits a span.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - await otel_async_client.list_monitors() - spans = span_exporter.get_finished_spans() - assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) - await otel_async_client.close() - - @respx.mock - async def test_async_span_has_attributes( - self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Async span carries http.request.method and url.full.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - await otel_async_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - attrs = spans[0].attributes or {} - assert attrs.get("http.request.method") == "GET" - assert "url.full" in attrs - await otel_async_client.close() - - @respx.mock - async def test_async_span_records_error( - self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter - ) -> None: - """Async span status is ERROR on 500.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(500, json={"error": "Server Error"}) - ) - with pytest.raises(HyperpingAPIError): - await otel_async_client.list_monitors() - spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - await otel_async_client.close() - - -# ── TestMcpTransportSpans ────────────────────────────────────────────────────── - - -class TestMcpTransportSpans: - @respx.mock - def test_mcp_rpc_creates_span( - self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """tools/call emits a span named 'hyperping.mcp tools/call'.""" - respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) - otel_mcp.call_tool("test_tool") - spans = span_exporter.get_finished_spans() - assert any(s.name == "hyperping.mcp tools/call" for s in spans) - - @respx.mock - def test_mcp_span_has_rpc_method_attribute( - self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """MCP span carries rpc.method = 'tools/call'.""" - respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) - otel_mcp.call_tool("test_tool") - spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] - assert spans - assert spans[0].attributes.get("rpc.method") == "tools/call" # type: ignore[union-attr] - - @respx.mock - def test_mcp_span_has_rpc_system_attribute( - self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """MCP span carries rpc.system = 'jsonrpc'.""" - respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) - otel_mcp.call_tool("test_tool") - spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] - assert spans - assert spans[0].attributes.get("rpc.system") == "jsonrpc" # type: ignore[union-attr] - - @respx.mock - def test_mcp_span_records_error_on_rpc_error( - self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """MCP span status is ERROR when JSON-RPC returns an error.""" - respx.post(MCP_URL).mock( - return_value=httpx.Response( - 200, - json={"jsonrpc": "2.0", "id": 2, "error": {"code": -32600, "message": "Bad req"}}, - ) - ) - with pytest.raises(HyperpingAPIError): - otel_mcp.call_tool("test_tool") - spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - - @respx.mock - def test_mcp_span_records_error_on_http_error( - self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """MCP span status is ERROR on HTTP 500.""" - respx.post(MCP_URL).mock(return_value=httpx.Response(500)) - with pytest.raises(HyperpingAPIError): - otel_mcp.call_tool("test_tool") - spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - - -# ── TestAsyncMcpTransportSpans ───────────────────────────────────────────────── - - -class TestAsyncMcpTransportSpans: - @respx.mock - async def test_async_mcp_rpc_creates_span( - self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """Async tools/call emits 'hyperping.mcp tools/call'.""" - respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) - await otel_async_mcp.call_tool("test_tool") - spans = span_exporter.get_finished_spans() - assert any(s.name == "hyperping.mcp tools/call" for s in spans) - await otel_async_mcp.close() - - @respx.mock - async def test_async_mcp_span_records_error( - self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter - ) -> None: - """Async MCP span status is ERROR on HTTP 500.""" - respx.post(MCP_URL).mock(return_value=httpx.Response(500)) - with pytest.raises(HyperpingAPIError): - await otel_async_mcp.call_tool("test_tool") - spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] - assert spans - assert spans[0].status.status_code == StatusCode.ERROR - await otel_async_mcp.close() - - -# ── TestNoopWhenUninstalled ──────────────────────────────────────────────────── - - -class TestNoopWhenUninstalled: - @respx.mock - def test_sync_client_works_without_otel(self) -> None: - """Client works normally when _tracer is None (simulates HAS_OTEL=False).""" - import hyperping._otel as otel_mod - - with patch.object(otel_mod, "HAS_OTEL", False): - c = HyperpingClient( - api_key="sk_test_key", - base_url=API_BASE, - retry_config=RetryConfig(max_retries=0), - ) - assert c._tracer is None - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - result = c.list_monitors() - assert result == [] - c.close() - - @respx.mock - async def test_async_client_works_without_otel(self) -> None: - """Async client works normally when _tracer is None.""" - import hyperping._otel as otel_mod - - with patch.object(otel_mod, "HAS_OTEL", False): - c = AsyncHyperpingClient( - api_key="sk_test_key", - base_url=API_BASE, - retry_config=RetryConfig(max_retries=0), - ) - assert c._tracer is None - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) - result = await c.list_monitors() - assert result == [] - await c.close() - - @respx.mock - def test_mcp_transport_works_without_otel(self) -> None: - """MCP transport works normally when _tracer is None.""" - import hyperping._otel as otel_mod - - with patch.object(otel_mod, "HAS_OTEL", False): - t = McpTransport(api_key="sk_test", base_url=MCP_URL) - assert t._tracer is None - t._initialized = True - t._init_result = {"protocolVersion": "2025-03-26"} - respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) - result = t.call_tool("test_tool") - assert result == {"ok": True} - t.close() diff --git a/uv.lock b/uv.lock index 5481d99..f4435d2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -372,19 +381,18 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "typer" }, +] dev = [ { name = "mypy" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, { name = "pip-audit" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "respx" }, { name = "ruff" }, -] -otel = [ - { name = "opentelemetry-api" }, + { name = "typer" }, ] [package.dev-dependencies] @@ -396,9 +404,6 @@ dev = [ requires-dist = [ { name = "httpx2", specifier = ">=2.4,<3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "opentelemetry-api", marker = "extra == 'dev'", specifier = ">=1.20" }, - { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, - { name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.20" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, { name = "pydantic", marker = "extra == 'dev'" }, @@ -406,8 +411,10 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "typer", extras = ["all"], marker = "extra == 'cli'", specifier = ">=0.12,<1.0" }, + { name = "typer", extras = ["all"], marker = "extra == 'dev'", specifier = ">=0.12,<1.0" }, ] -provides-extras = ["otel", "dev"] +provides-extras = ["cli", "dev"] [package.metadata.requires-dev] dev = [{ name = "pytest-asyncio", specifier = ">=0.23.0" }] @@ -637,45 +644,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "opentelemetry-api" -version = "1.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.63b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, -] - [[package]] name = "packageurl-python" version = "0.17.6" @@ -1026,6 +994,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1107,6 +1084,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"