diff --git a/.changelog/5320.added b/.changelog/5320.added new file mode 100644 index 0000000000..ab71930829 --- /dev/null +++ b/.changelog/5320.added @@ -0,0 +1 @@ +`opentelemetry-exporter-http-transport`: enable entry-point loading of transport implementations diff --git a/exporter/opentelemetry-exporter-http-transport/pyproject.toml b/exporter/opentelemetry-exporter-http-transport/pyproject.toml index e2ecb426cb..40ca4774a5 100644 --- a/exporter/opentelemetry-exporter-http-transport/pyproject.toml +++ b/exporter/opentelemetry-exporter-http-transport/pyproject.toml @@ -25,7 +25,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [] +dependencies = [ + "opentelemetry-api ~= 1.15", +] [project.optional-dependencies] urllib3 = [ @@ -39,6 +41,10 @@ requests = [ Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/exporter/opentelemetry-exporter-http-transport" Repository = "https://github.com/open-telemetry/opentelemetry-python" +[project.entry-points.opentelemetry_http_transport] +urllib3 = "opentelemetry.exporter.http.transport._urllib3:Urllib3HTTPTransport" +requests = "opentelemetry.exporter.http.transport._requests:RequestsHTTPTransport" + [tool.hatch.version] path = "src/opentelemetry/exporter/http/transport/version/__init__.py" diff --git a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/__init__.py b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/__init__.py index e57cf4aba9..85552503dc 100644 --- a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/__init__.py +++ b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/__init__.py @@ -1,2 +1,79 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from opentelemetry.exporter.http.transport._base import BaseHTTPTransport + + +def _load_requests_transport() -> type[BaseHTTPTransport]: + # pylint: disable-next=import-outside-toplevel,import-error + from opentelemetry.exporter.http.transport._requests import ( # noqa: PLC0415 + RequestsHTTPTransport, + ) + + return RequestsHTTPTransport + + +def _load_urllib3_transport() -> type[BaseHTTPTransport]: + # pylint: disable-next=import-outside-toplevel,import-error + from opentelemetry.exporter.http.transport._urllib3 import ( # noqa: PLC0415 + Urllib3HTTPTransport, + ) + + return Urllib3HTTPTransport + + +_KNOWN_TRANSPORTS: dict[str, Callable[[], type[BaseHTTPTransport]]] = { + "requests": _load_requests_transport, + "urllib3": _load_urllib3_transport, +} + + +def _load_http_transport_class(name: str) -> type[BaseHTTPTransport]: + """Return the transport class registered under *name*. + + Checks the built-in transport registry first to avoid the overhead of an + entry-point scan for built-in transports. Falls back to standard entry-point + discovery for user supplied transports registered under the + ``opentelemetry_http_transport`` group. + + :param name: Entry point name, e.g. ``"requests"`` or ``"urllib3"``. + :returns: The :class:`~opentelemetry.exporter.http.transport._base.BaseHTTPTransport` + subclass registered under *name*. + :raises ValueError: If no transport is registered under *name*. + """ + if name in _KNOWN_TRANSPORTS: + return _KNOWN_TRANSPORTS[name]() + # pylint: disable-next=import-outside-toplevel,import-error + from opentelemetry.util._importlib_metadata import ( # noqa: PLC0415 + entry_points, + ) + + ep = next( + iter(entry_points(group="opentelemetry_http_transport", name=name)), + None, + ) + if not ep: + raise ValueError( + f"No HTTP transport registered under name {name!r}. " + "Install the corresponding extra or register an entry point " + "under the 'opentelemetry_http_transport' group." + ) + cls = ep.load() + # pylint: disable-next=import-outside-toplevel,import-error + from opentelemetry.exporter.http.transport._base import ( # noqa: PLC0415 + BaseHTTPTransport, + ) + + if not isinstance(cls, type) or not issubclass(cls, BaseHTTPTransport): + raise TypeError( + f"Transport {name!r} loaded from entry point does not subclass " + f"BaseHTTPTransport (got {cls!r})." + ) + return cls diff --git a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py index 20602ac3d4..1951cb720d 100644 --- a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py +++ b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py @@ -1,11 +1,18 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + import json from abc import ABC, abstractmethod -from collections.abc import Mapping from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + + from typing_extensions import Self @dataclass(frozen=True, slots=True) @@ -57,6 +64,30 @@ def json(self) -> Any: class BaseHTTPTransport(ABC): """Abstract HTTP transport interface used by HTTP exporters.""" + @classmethod + @abstractmethod + def create( + cls, + verify: bool | str, + cert: str | tuple[str, str] | None, + **kwargs: Any, + ) -> Self: + """Create a new transport instance. + + :param verify: Controls TLS certificate verification. ``True`` verifies + the server certificate against the system CA bundle, ``False`` + disables verification, a string is interpreted as a path to a CA + bundle file to use instead of the system bundle. + :param cert: Client certificate for mutual TLS. Pass a string path to a + PEM-encoded certificate file (the file must contain both the + certificate and the key), or a ``(cert_file, key_file)`` tuple when + the private key is in a separate file. Pass ``None`` to disable + client side authentication. + :param kwargs: Additional keyword arguments forwarded to the transport + implementation. + :returns: A new transport instance. + """ + @abstractmethod def request( self, diff --git a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_requests.py b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_requests.py index 637fd93b51..b7b51d4acc 100644 --- a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_requests.py +++ b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_requests.py @@ -4,9 +4,8 @@ from __future__ import annotations import functools -from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING # pylint: disable-next=import-error from opentelemetry.exporter.http.transport._base import ( @@ -15,8 +14,12 @@ ) if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + import requests from requests import Response + from typing_extensions import Self @functools.cache @@ -60,12 +63,22 @@ def headers(self) -> Mapping[str, str]: class RequestsHTTPTransport(BaseHTTPTransport): + @classmethod + def create( + cls, + verify: bool | str, + cert: str | tuple[str, str] | None, + **kwargs: dict[str, Any], + ) -> Self: + return cls(verify=verify, cert=cert, **kwargs) + def __init__( self, *, verify: bool | str = True, cert: str | tuple[str, str] | None = None, session: requests.Session | None = None, + **kwargs: dict[str, Any], ) -> None: # pylint: disable-next=import-outside-toplevel import requests # noqa: PLC0415 diff --git a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_urllib3.py b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_urllib3.py index 57a68bd967..64fb04db29 100644 --- a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_urllib3.py +++ b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_urllib3.py @@ -5,9 +5,8 @@ import functools import json -from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING # pylint: disable-next=import-error from opentelemetry.exporter.http.transport._base import ( @@ -16,6 +15,10 @@ ) if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + + from typing_extensions import Self from urllib3 import BaseHTTPResponse @@ -65,11 +68,21 @@ def json(self) -> Any: class Urllib3HTTPTransport(BaseHTTPTransport): + @classmethod + def create( + cls, + verify: bool | str, + cert: str | tuple[str, str] | None, + **kwargs: dict[str, Any], + ) -> Self: + return cls(verify=verify, cert=cert, **kwargs) + def __init__( self, *, verify: bool | str = True, cert: str | tuple[str, str] | None = None, + **kwargs: dict[str, Any], ) -> None: # pylint: disable-next=import-outside-toplevel import urllib3 # noqa: PLC0415 diff --git a/exporter/opentelemetry-exporter-http-transport/test-requirements.in b/exporter/opentelemetry-exporter-http-transport/test-requirements.in index e8b1ac224d..c9de583437 100644 --- a/exporter/opentelemetry-exporter-http-transport/test-requirements.in +++ b/exporter/opentelemetry-exporter-http-transport/test-requirements.in @@ -3,4 +3,5 @@ mocket==3.14.1 packaging==26.2 pluggy==1.6.0 pytest==7.4.4 +-e opentelemetry-api -e exporter/opentelemetry-exporter-http-transport[urllib3,requests] diff --git a/exporter/opentelemetry-exporter-http-transport/test-requirements.latest.txt b/exporter/opentelemetry-exporter-http-transport/test-requirements.latest.txt index b1ec00aea6..c2d364fb3d 100644 --- a/exporter/opentelemetry-exporter-http-transport/test-requirements.latest.txt +++ b/exporter/opentelemetry-exporter-http-transport/test-requirements.latest.txt @@ -2,6 +2,10 @@ # uv pip compile --python 3.10 --universal --resolution highest exporter/opentelemetry-exporter-http-transport/test-requirements.in -o exporter/opentelemetry-exporter-http-transport/test-requirements.latest.txt -e exporter/opentelemetry-exporter-http-transport # via -r exporter/opentelemetry-exporter-http-transport/test-requirements.in +-e opentelemetry-api + # via + # -r exporter/opentelemetry-exporter-http-transport/test-requirements.in + # opentelemetry-exporter-http-transport certifi==2026.4.22 # via requests charset-normalizer==3.4.7 @@ -44,6 +48,7 @@ typing-extensions==4.15.0 # via # exceptiongroup # mocket + # opentelemetry-api urllib3==2.7.0 # via # mocket diff --git a/exporter/opentelemetry-exporter-http-transport/test-requirements.oldest.txt b/exporter/opentelemetry-exporter-http-transport/test-requirements.oldest.txt index c9b33dc117..63a0f998c8 100644 --- a/exporter/opentelemetry-exporter-http-transport/test-requirements.oldest.txt +++ b/exporter/opentelemetry-exporter-http-transport/test-requirements.oldest.txt @@ -2,6 +2,10 @@ # uv pip compile --python 3.10 --universal --resolution lowest-direct exporter/opentelemetry-exporter-http-transport/test-requirements.in -o exporter/opentelemetry-exporter-http-transport/test-requirements.oldest.txt -e exporter/opentelemetry-exporter-http-transport # via -r exporter/opentelemetry-exporter-http-transport/test-requirements.in +-e opentelemetry-api + # via + # -r exporter/opentelemetry-exporter-http-transport/test-requirements.in + # opentelemetry-exporter-http-transport certifi==2026.4.22 # via requests chardet==3.0.4 @@ -44,6 +48,7 @@ typing-extensions==4.15.0 # via # exceptiongroup # mocket + # opentelemetry-api urllib3==1.26.20 # via # mocket diff --git a/exporter/opentelemetry-exporter-http-transport/tests/test_load_transport.py b/exporter/opentelemetry-exporter-http-transport/tests/test_load_transport.py new file mode 100644 index 0000000000..6e2baadc01 --- /dev/null +++ b/exporter/opentelemetry-exporter-http-transport/tests/test_load_transport.py @@ -0,0 +1,72 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# pylint: disable=import-error + +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.exporter.http.transport import _load_http_transport_class +from opentelemetry.exporter.http.transport._base import BaseHTTPTransport +from opentelemetry.exporter.http.transport._requests import ( + RequestsHTTPTransport, +) +from opentelemetry.exporter.http.transport._urllib3 import ( + Urllib3HTTPTransport, +) + +_ENTRY_POINTS_TARGET = "opentelemetry.util._importlib_metadata.entry_points" + + +# pylint: disable=no-self-use +class TestLoadHTTPTransportClass(unittest.TestCase): + def test_returns_requests_transport(self): + self.assertIs( + _load_http_transport_class("requests"), RequestsHTTPTransport + ) + + def test_returns_urllib3_transport(self): + self.assertIs( + _load_http_transport_class("urllib3"), Urllib3HTTPTransport + ) + + def test_known_transport_does_not_call_entry_points(self): + with patch(_ENTRY_POINTS_TARGET) as mock_ep: + _load_http_transport_class("requests") + _load_http_transport_class("urllib3") + self.assertFalse(mock_ep.called) + + def test_unknown_transport_calls_entry_points(self): + class _CustomTransport: + pass + + BaseHTTPTransport.register(_CustomTransport) + mock_ep = MagicMock() + mock_ep.load.return_value = _CustomTransport + with patch(_ENTRY_POINTS_TARGET, return_value=[mock_ep]) as mock_fn: + result = _load_http_transport_class("custom") + self.assertEqual( + mock_fn.call_args.kwargs, + {"group": "opentelemetry_http_transport", "name": "custom"}, + ) + self.assertIs(result, _CustomTransport) + + def test_entry_point_non_subclass_raises_type_error(self): + class _NotATransport: + pass + + mock_ep = MagicMock() + mock_ep.load.return_value = _NotATransport + with patch(_ENTRY_POINTS_TARGET, return_value=[mock_ep]): + self.assertRaises(TypeError, _load_http_transport_class, "bad") + + def test_entry_point_non_class_raises_type_error(self): + mock_ep = MagicMock() + mock_ep.load.return_value = object() + with patch(_ENTRY_POINTS_TARGET, return_value=[mock_ep]): + self.assertRaises(TypeError, _load_http_transport_class, "bad") + + def test_unknown_transport_raises_value_error(self): + with patch(_ENTRY_POINTS_TARGET, return_value=[]): + self.assertRaises( + ValueError, _load_http_transport_class, "nonexistent" + ) diff --git a/exporter/opentelemetry-exporter-http-transport/tests/test_requests_transport.py b/exporter/opentelemetry-exporter-http-transport/tests/test_requests_transport.py index 3d0b11ecc7..33a6115e9c 100644 --- a/exporter/opentelemetry-exporter-http-transport/tests/test_requests_transport.py +++ b/exporter/opentelemetry-exporter-http-transport/tests/test_requests_transport.py @@ -261,3 +261,46 @@ def test_close_closes_session(self): transport = RequestsHTTPTransport(session=mock_session) transport.close() mock_session.close.assert_called_once() + + def test_create_returns_transport_instance(self): + mock_session = MagicMock(spec=requests.Session) + transport = RequestsHTTPTransport.create( + True, None, session=mock_session + ) + self.assertIsInstance(transport, RequestsHTTPTransport) + + def test_create_forwards_verify(self): + cases = [ + (True, True), + (False, False), + ("/path/to/ca.pem", "/path/to/ca.pem"), + ] + for verify, expected in cases: + with self.subTest(verify=verify): + mock_session = MagicMock(spec=requests.Session) + RequestsHTTPTransport.create( + verify, None, session=mock_session + ) + self.assertEqual(mock_session.verify, expected) + + def test_create_forwards_cert(self): + cases = [ + "/path/to/cert.pem", + ("/path/to/cert.pem", "/path/to/key.pem"), + ] + for cert in cases: + with self.subTest(cert=cert): + mock_session = MagicMock(spec=requests.Session) + RequestsHTTPTransport.create(True, cert, session=mock_session) + self.assertEqual(mock_session.cert, cert) + + def test_create_forwards_session_kwarg(self): + mock_session = MagicMock(spec=requests.Session) + mock_session.request.return_value = MagicMock( + status_code=200, reason="OK" + ) + transport = RequestsHTTPTransport.create( + True, None, session=mock_session + ) + transport.request("POST", _TEST_URL) + mock_session.request.assert_called_once() diff --git a/exporter/opentelemetry-exporter-http-transport/tests/test_urllib3_transport.py b/exporter/opentelemetry-exporter-http-transport/tests/test_urllib3_transport.py index 03e4171ea7..18cd23afa2 100644 --- a/exporter/opentelemetry-exporter-http-transport/tests/test_urllib3_transport.py +++ b/exporter/opentelemetry-exporter-http-transport/tests/test_urllib3_transport.py @@ -303,3 +303,45 @@ def test_close_clears_pool(self): transport = Urllib3HTTPTransport() transport.close() mock_pm.return_value.clear.assert_called_once() + + def test_create_returns_transport_instance(self): + with patch("urllib3.PoolManager"): + transport = Urllib3HTTPTransport.create(True, None) + self.assertIsInstance(transport, Urllib3HTTPTransport) + + def test_create_forwards_verify(self): + cases = [ + (True, "CERT_REQUIRED", None), + (False, "CERT_NONE", None), + ("/path/to/ca.pem", "CERT_REQUIRED", "/path/to/ca.pem"), + ] + for verify, expected_cert_reqs, expected_ca_certs in cases: + with self.subTest(verify=verify): + with patch("urllib3.PoolManager") as mock_pm: + Urllib3HTTPTransport.create(verify, None) + kwargs = mock_pm.call_args.kwargs + self.assertEqual(kwargs["cert_reqs"], expected_cert_reqs) + if expected_ca_certs is not None: + self.assertEqual(kwargs["ca_certs"], expected_ca_certs) + else: + self.assertNotIn("ca_certs", kwargs) + + def test_create_forwards_cert(self): + cases = [ + ("/path/to/cert.pem", "/path/to/cert.pem", None), + ( + ("/path/to/cert.pem", "/path/to/key.pem"), + "/path/to/cert.pem", + "/path/to/key.pem", + ), + ] + for cert, expected_cert_file, expected_key_file in cases: + with self.subTest(cert=cert): + with patch("urllib3.PoolManager") as mock_pm: + Urllib3HTTPTransport.create(True, cert) + kwargs = mock_pm.call_args.kwargs + self.assertEqual(kwargs["cert_file"], expected_cert_file) + if expected_key_file is not None: + self.assertEqual(kwargs["key_file"], expected_key_file) + else: + self.assertNotIn("key_file", kwargs) diff --git a/uv.lock b/uv.lock index e307a06639..ec806543d8 100644 --- a/uv.lock +++ b/uv.lock @@ -845,6 +845,9 @@ wheels = [ [[package]] name = "opentelemetry-exporter-http-transport" source = { editable = "exporter/opentelemetry-exporter-http-transport" } +dependencies = [ + { name = "opentelemetry-api" }, +] [package.optional-dependencies] requests = [ @@ -856,6 +859,7 @@ urllib3 = [ [package.metadata] requires-dist = [ + { name = "opentelemetry-api", editable = "opentelemetry-api" }, { name = "requests", marker = "extra == 'requests'", specifier = "~=2.25" }, { name = "urllib3", marker = "extra == 'urllib3'", specifier = ">=1.26" }, ]