diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/logtide_sdk/async_client.py b/logtide_sdk/async_client.py index b8a6198..e7b1f0e 100644 --- a/logtide_sdk/async_client.py +++ b/logtide_sdk/async_client.py @@ -17,15 +17,13 @@ "Install it with: pip install logtide-sdk[async]" ) +from logtide_sdk._retry import classify_failure +from logtide_sdk._version import SDK_NAME, VERSION from logtide_sdk.circuit_breaker import CircuitBreaker -from logtide_sdk.client import _process_value, serialize_exception +from logtide_sdk.client import serialize_exception from logtide_sdk.enums import CircuitState, LogLevel from logtide_sdk.exceptions import CircuitBreakerOpenError from logtide_sdk.json_encoder import logtide_json_dumps -from logtide_sdk._retry import classify_failure -from logtide_sdk._version import SDK_NAME, VERSION -from logtide_sdk.scope import get_current_scope -from logtide_sdk.tracecontext import active_trace_context, generate_trace_id from logtide_sdk.models import ( AggregatedStatsOptions, AggregatedStatsResponse, @@ -36,6 +34,9 @@ PayloadLimitsOptions, QueryOptions, ) +from logtide_sdk.payload_limits import apply_payload_limits +from logtide_sdk.scope import get_current_scope +from logtide_sdk.tracecontext import active_trace_context, generate_trace_id class AsyncLogTideClient: @@ -617,7 +618,7 @@ def _apply_payload_limits(self, entry: LogEntry) -> None: if not entry.metadata: return lim = self._payload_limits - entry.metadata = _process_value(entry.metadata, "root", lim) + entry.metadata = apply_payload_limits(entry.metadata, "root", lim) raw = logtide_json_dumps(entry) if len(raw.encode()) > lim.max_log_size: diff --git a/logtide_sdk/client.py b/logtide_sdk/client.py index ecb4007..1aef9e7 100644 --- a/logtide_sdk/client.py +++ b/logtide_sdk/client.py @@ -4,7 +4,6 @@ import dataclasses import json import random -import re import time import traceback from collections.abc import Callable, Iterator @@ -14,14 +13,12 @@ import requests +from logtide_sdk._retry import classify_failure +from logtide_sdk._version import SDK_NAME, VERSION from logtide_sdk.circuit_breaker import CircuitBreaker from logtide_sdk.enums import CircuitState, LogLevel from logtide_sdk.exceptions import CircuitBreakerOpenError from logtide_sdk.json_encoder import logtide_json_dumps -from logtide_sdk._retry import classify_failure -from logtide_sdk._version import SDK_NAME, VERSION -from logtide_sdk.scope import get_current_scope -from logtide_sdk.tracecontext import active_trace_context, generate_trace_id from logtide_sdk.models import ( AggregatedStatsOptions, AggregatedStatsResponse, @@ -32,20 +29,14 @@ PayloadLimitsOptions, QueryOptions, ) +from logtide_sdk.payload_limits import apply_payload_limits +from logtide_sdk.scope import get_current_scope +from logtide_sdk.tracecontext import active_trace_context, generate_trace_id # --------------------------------------------------------------------------- # Module-level helpers (importable by async_client and middleware) # --------------------------------------------------------------------------- -_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=]{100,}$") - - -def _looks_like_base64(s: str) -> bool: - """Return True if the string looks like base64-encoded or data-URI data.""" - if s.startswith("data:"): - return True - return bool(_BASE64_RE.match(s.replace("\n", "").replace("\r", ""))) - def serialize_exception(exc: BaseException) -> dict[str, Any]: """ @@ -82,31 +73,6 @@ def serialize_exception(exc: BaseException) -> dict[str, Any]: return result -def _process_value(value: Any, path: str, lim: PayloadLimitsOptions) -> Any: - """Recursively apply payload limits to a metadata value.""" - if value is None: - return - - field_name = path.split(".")[-1] - if field_name in lim.exclude_fields: - return "[EXCLUDED]" - - if isinstance(value, str): - if len(value) >= 100 and _looks_like_base64(value): - return "[BASE64 DATA REMOVED]" - if len(value) > lim.max_field_size: - return value[: lim.max_field_size] + lim.truncation_marker - return value - - if isinstance(value, dict): - return {k: _process_value(v, f"{path}.{k}", lim) for k, v in value.items()} - - if isinstance(value, list): - return [_process_value(v, f"{path}[{i}]", lim) for i, v in enumerate(value)] - - return value - - # --------------------------------------------------------------------------- # Main client # --------------------------------------------------------------------------- @@ -236,7 +202,6 @@ def _resolve_call( resolved_payload = message_or_payload if message_or_payload is not None else payload return self.options.service, service_or_message, resolved_payload - def log(self, entry: LogEntry) -> None: """ Log a pre-built entry. Applies trace ID, global metadata, and @@ -245,9 +210,16 @@ def log(self, entry: LogEntry) -> None: Args: entry: Log entry to send """ - if self._closed: + # TODO: this method is long (lines of code), need to split it up + + if self._closed or self.options.local_mode is True: return + if self.options.local_mode == "if_unset_api_key" and not self.options.api_key: + return + + # if self.options + # Coerce None to {} so unpacking never raises TypeError if entry.metadata is None: entry.metadata = {} @@ -298,9 +270,9 @@ def log(self, entry: LogEntry) -> None: if self.options.sample_rate < 1.0 and random.random() > self.options.sample_rate: return - # Apply payload limits before buffering self._apply_payload_limits(entry) + # TODO: move than logic from there should_flush = False with self._buffer_lock: if len(self._buffer) >= self.options.max_buffer_size: @@ -829,7 +801,7 @@ def _apply_payload_limits(self, entry: LogEntry) -> None: return lim = self._payload_limits - entry.metadata = _process_value(entry.metadata, "root", lim) + entry.metadata = apply_payload_limits(entry.metadata, "root", lim) # Enforce total entry size raw = logtide_json_dumps(entry) diff --git a/logtide_sdk/models.py b/logtide_sdk/models.py index 5948783..835e03a 100644 --- a/logtide_sdk/models.py +++ b/logtide_sdk/models.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any +from typing import Any, Literal from logtide_sdk.dsn import parse_dsn from logtide_sdk.enums import LogLevel @@ -64,8 +64,9 @@ class ClientOptions: Provide either ``dsn`` or ``api_url`` + ``api_key``. """ - api_url: str = "" - api_key: str = "" + api_url: str = "https://api.logtide.dev" + api_key: str | None = None + local_mode: bool | Literal["if_unset_api_key"] = False batch_size: int = 100 flush_interval: int = 5000 max_buffer_size: int = 10000 @@ -86,17 +87,24 @@ class ClientOptions: def __post_init__(self) -> None: if not 0.0 <= self.sample_rate <= 1.0: raise ValueError("sample_rate must be between 0.0 and 1.0") + if self.dsn: parts = parse_dsn(self.dsn) - if not self.api_url: - self.api_url = parts.api_url - if not self.api_key: - self.api_key = parts.api_key - if not self.api_url or not self.api_key: + self.api_url = parts.api_url + self.api_key = parts.api_key + + if ( + self.local_mode + and self.local_mode is not True + and self.local_mode != "if_unset_api_key" + ): raise ValueError( - "Either dsn or api_url + api_key must be provided to ClientOptions" + "Local mode cannot be positive value other then True or 'if_unset_api_key'" ) + if self.local_mode is False and (not self.api_url or not self.api_key): + raise ValueError("Either dsn or api_url + api_key must be provided to ClientOptions") + @dataclass class QueryOptions: diff --git a/logtide_sdk/payload_limits.py b/logtide_sdk/payload_limits.py new file mode 100644 index 0000000..1f51fbc --- /dev/null +++ b/logtide_sdk/payload_limits.py @@ -0,0 +1,40 @@ +import re +from typing import Any + +from logtide_sdk.models import PayloadLimitsOptions + + +def apply_payload_limits(value: Any, path: str, lim: PayloadLimitsOptions) -> Any: + """Recursively apply payload limits and hide base64 to a metadata value.""" + + if value is None: + return + + field_name = path.split(".")[-1] + if field_name in lim.exclude_fields: + return "[EXCLUDED]" + + if isinstance(value, str): + if len(value) >= 100 and _looks_like_base64(value): + return "[BASE64 DATA REMOVED]" + if len(value) > lim.max_field_size: + return value[: lim.max_field_size] + lim.truncation_marker + return value + + if isinstance(value, dict): + return {k: apply_payload_limits(v, f"{path}.{k}", lim) for k, v in value.items()} + + if isinstance(value, list): + return [apply_payload_limits(v, f"{path}[{i}]", lim) for i, v in enumerate(value)] + + return value + + +_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=]{100,}$") + + +def _looks_like_base64(s: str) -> bool: + """Return True if the string looks like base64-encoded or data-URI data.""" + if s.startswith("data:"): + return True + return bool(_BASE64_RE.match(s.replace("\n", "").replace("\r", ""))) diff --git a/pyproject.toml b/pyproject.toml index d940152..8772a0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,10 @@ Issues = "https://github.com/logtide-dev/logtide-python/issues" [project.optional-dependencies] async = ["aiohttp>=3.9.0"] -otel = ["opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp-proto-http>=1.20.0"] +otel = [ + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp-proto-http>=1.20.0", +] starlette = ["starlette>=0.27.0"] flask = ["flask>=2.0.0"] django = ["django>=3.2.0"] @@ -67,11 +70,11 @@ tests = [ "fastapi>=0.100.0", "starlette>=0.27.0", "build", - "pytest>=8.3.5", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", "freezegun>=1.5.5", "pytest-mock>=3.14.1", + "pytest>=9.0.3", ] all = [{ include-group = "tests" }] @@ -119,3 +122,10 @@ python_classes = ["Test*"] python_functions = ["test_*"] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +reportUnnecessaryTypeIgnoreComment = true +reportMissingTypeStubs = false +include = ["logtide_sdk", "tests"] +venvPath = "." +venv = ".venv" diff --git a/tests/test_client.py b/tests/test_client.py index ad62d14..fb8c321 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ """Basic tests for LogTide SDK.""" from dataclasses import dataclass +from typing import Literal from unittest.mock import MagicMock import pytest @@ -16,17 +17,8 @@ from logtide_sdk.models import LogEntry -@pytest.fixture -def client() -> LogTideClient: - return LogTideClient( - ClientOptions( - api_url="http://localhost:8080", - api_key="test_key", - ) - ) - - def test_client_initialization(): + # leave it here as part of the test client = LogTideClient( ClientOptions( api_url="http://localhost:8080", @@ -39,6 +31,19 @@ def test_client_initialization(): client.close() +@pytest.fixture +def options() -> ClientOptions: + return ClientOptions( + api_url="http://localhost:8080", + api_key="test_key", + ) + + +@pytest.fixture +def client(options: ClientOptions) -> LogTideClient: + return LogTideClient(options) + + def test_logging_methods(client: LogTideClient): # Test all log levels client.debug("test-service", "Debug message") @@ -231,3 +236,45 @@ def test_flush_with_unjsonable_payload_and_no_trace_id( data=json_string, timeout=30, ) + + +@pytest.mark.parametrize("local_mode", ["if_unset_api_key", True]) +def test_log_does_nothing_in_local_mode( + mocker: MockerFixture, local_mode: Literal["if_unset_api_key"] | bool +) -> None: + client = LogTideClient(ClientOptions(api_url="https://someapiurl.com", local_mode=local_mode)) + apply_payload_limits_mock = mocker.patch.object(client, "_apply_payload_limits") + flush_mock = mocker.patch.object(client, "flush") + + client.log( + LogEntry( + service="randomService-name", + level=LogLevel.INFO, + message="Some random message that shouldn't be sent", + ) + ) + + apply_payload_limits_mock.assert_not_called() + flush_mock.assert_not_called() + + assert len(client._buffer) == 0 + + +def test_log_does_smth_if_api_key_and_local_if_unset_api_key(mocker: MockerFixture) -> None: + client = LogTideClient( + ClientOptions( + api_url="https://someapiurl.com", + api_key="abc_some_api_key", + local_mode="if_unset_api_key", + ) + ) + + client.log( + LogEntry( + service="randomService-name", + level=LogLevel.INFO, + message="Some random message that shouldn't be sent", + ) + ) + + assert len(client._buffer) != 0 diff --git a/tests/test_dsn.py b/tests/test_dsn.py new file mode 100644 index 0000000..63ab277 --- /dev/null +++ b/tests/test_dsn.py @@ -0,0 +1,35 @@ +import pytest + +from logtide_sdk.dsn import DsnParseError, parse_dsn + + +def test_parse_dsn_basic() -> None: + parts = parse_dsn("https://lp_abc123@logs.example.com") + assert parts.api_url == "https://logs.example.com" + assert parts.api_key == "lp_abc123" + + +def test_parse_dsn_preserves_base_path() -> None: + parts = parse_dsn("https://lp_abc123@logs.example.com/logtide") + assert parts.api_url == "https://logs.example.com/logtide" + + +def test_parse_dsn_http_and_port() -> None: + parts = parse_dsn("http://lp_k@localhost:8080") + assert parts.api_url == "http://localhost:8080" + assert parts.api_key == "lp_k" + + +@pytest.mark.parametrize( + "dsn", + [ + "", + "not-a-dsn", + "ftp://lp_k@host", # bad scheme + "https://logs.example.com", # no key + "https://@logs.example.com", # empty key + ], +) +def test_parse_dsn_rejects_invalid(dsn) -> None: + with pytest.raises(DsnParseError): + parse_dsn(dsn) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..7cf8761 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,38 @@ +from typing import Literal + +import pytest + +from logtide_sdk.models import ClientOptions + + +def test_client_options_requires_dsn_or_url_and_key_by_default() -> None: + with pytest.raises(ValueError, match="Either dsn or api_url"): + ClientOptions(local_mode=False) + + with pytest.raises(ValueError, match="Either dsn or api_url"): + ClientOptions(api_url="http://localhost:8080", api_key=None, local_mode=False) + + with pytest.raises(ValueError, match="Either dsn or api_url"): + ClientOptions(api_url="http://localhost:8080", api_key="") + + +@pytest.mark.parametrize("local_mode", ["if_unset_api_key", True]) +@pytest.mark.parametrize("api_key", [None, ""]) +def test_client_options_ignores_unset_api_key_if_set_unset_or_local_mode( + api_key: str, local_mode: Literal["if_unset_api_key"] | bool +) -> None: + ClientOptions(api_url="https://any.apiurl.dev", api_key=api_key, local_mode=local_mode) + + +def test_client_options_accepts_dsn() -> None: + opts = ClientOptions(dsn="https://lp_abc@logs.example.com") + + assert opts.api_url == "https://logs.example.com" + assert opts.api_key == "lp_abc" + + +def test_client_options_explicit_still_works() -> None: + opts = ClientOptions(api_url="http://localhost:8080", api_key="lp_k") + + assert opts.api_url == "http://localhost:8080" + assert opts.api_key == "lp_k" diff --git a/tests/test_spec_tier1.py b/tests/test_spec_tier1.py index 6ad3965..c849bb8 100644 --- a/tests/test_spec_tier1.py +++ b/tests/test_spec_tier1.py @@ -1,71 +1,18 @@ """Remaining Tier 1 spec items: DSN parsing (002 §3), service-in-options (004 §3) and the metadata.sdk stamp (003 §3).""" +from collections.abc import AsyncGenerator + import pytest import logtide_sdk from logtide_sdk import ClientOptions, LogTideClient -from logtide_sdk.dsn import DsnParseError, parse_dsn - - -# ---------------------------------------------------------------- DSN - - -def test_parse_dsn_basic(): - parts = parse_dsn("https://lp_abc123@logs.example.com") - assert parts.api_url == "https://logs.example.com" - assert parts.api_key == "lp_abc123" - - -def test_parse_dsn_preserves_base_path(): - parts = parse_dsn("https://lp_abc123@logs.example.com/logtide") - assert parts.api_url == "https://logs.example.com/logtide" - - -def test_parse_dsn_http_and_port(): - parts = parse_dsn("http://lp_k@localhost:8080") - assert parts.api_url == "http://localhost:8080" - assert parts.api_key == "lp_k" - - -@pytest.mark.parametrize( - "dsn", - [ - "", - "not-a-dsn", - "ftp://lp_k@host", # bad scheme - "https://logs.example.com", # no key - "https://@logs.example.com", # empty key - ], -) -def test_parse_dsn_rejects_invalid(dsn): - with pytest.raises(DsnParseError): - parse_dsn(dsn) - - -def test_client_options_accepts_dsn(): - opts = ClientOptions(dsn="https://lp_abc@logs.example.com") - assert opts.api_url == "https://logs.example.com" - assert opts.api_key == "lp_abc" - - -def test_client_options_requires_dsn_or_url_and_key(): - with pytest.raises(ValueError): - ClientOptions() - with pytest.raises(ValueError): - ClientOptions(api_url="http://localhost:8080") # key missing - - -def test_client_options_explicit_still_works(): - opts = ClientOptions(api_url="http://localhost:8080", api_key="lp_k") - assert opts.api_url == "http://localhost:8080" - # ------------------------------------------------- service in options @pytest.fixture -def client(): +def client() -> AsyncGenerator[LogTideClient]: c = LogTideClient( ClientOptions( api_url="http://localhost:8080", @@ -77,7 +24,7 @@ def client(): c._closed = True -def test_log_methods_use_configured_service(client): +def test_log_methods_use_configured_service(client) -> None: client.info("user logged in") entry = client._buffer[-1] assert entry.service == "checkout" @@ -102,7 +49,7 @@ def test_per_call_service_still_works(client): def test_message_only_without_configured_service_raises(): c = LogTideClient(ClientOptions(api_url="http://localhost:8080", api_key="lp_k")) try: - with pytest.raises(ValueError): + with pytest.raises(ValueError): # bad c.info("just a message") finally: c._closed = True diff --git a/uv.lock b/uv.lock index 69e6b0b..c0a3d99 100644 --- a/uv.lock +++ b/uv.lock @@ -709,6 +709,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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 = "idna" version = "3.16" @@ -762,7 +811,7 @@ wheels = [ [[package]] name = "logtide-sdk" -version = "0.9.0" +version = "0.9.8" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -783,6 +832,10 @@ fastapi = [ flask = [ { name = "flask" }, ] +otel = [ + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] starlette = [ { name = "starlette" }, ] @@ -796,6 +849,7 @@ all = [ { name = "fastapi" }, { name = "flask" }, { name = "freezegun" }, + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -810,6 +864,7 @@ tests = [ { name = "fastapi" }, { name = "flask" }, { name = "freezegun" }, + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -823,11 +878,13 @@ requires-dist = [ { name = "django", marker = "extra == 'django'", specifier = ">=3.2.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=2.0.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, { name = "requests", specifier = ">=2.31.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.27.0" }, ] -provides-extras = ["async", "starlette", "flask", "django", "fastapi"] +provides-extras = ["async", "otel", "starlette", "flask", "django", "fastapi"] [package.metadata.requires-dev] all = [ @@ -837,7 +894,8 @@ all = [ { name = "fastapi", specifier = ">=0.100.0" }, { name = "flask", specifier = ">=2.0.0" }, { name = "freezegun", specifier = ">=1.5.5" }, - { name = "pytest", specifier = ">=8.3.5" }, + { name = "httpx", specifier = ">=0.24.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-mock", specifier = ">=3.14.1" }, @@ -850,7 +908,8 @@ tests = [ { name = "fastapi", specifier = ">=0.100.0" }, { name = "flask", specifier = ">=2.0.0" }, { name = "freezegun", specifier = ">=1.5.5" }, - { name = "pytest", specifier = ">=8.3.5" }, + { name = "httpx", specifier = ">=0.24.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-mock", specifier = ">=3.14.1" }, @@ -1080,6 +1139,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[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-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[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 = "packaging" version = "26.2" @@ -1226,6 +1366,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pydantic" version = "2.13.4"