From 41617eb4aa011529738286228b3139f7429e86f8 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:51:51 +0400 Subject: [PATCH 01/31] chore!: require Python 3.11 or newer (#1927) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- .github/workflows/ci.yml | 1 - AGENTS.md | 2 +- langfuse/_client/observe.py | 38 ++--- langfuse/types.py | 7 +- pyproject.toml | 4 +- tests/e2e/test_decorators.py | 4 - tests/unit/test_observe.py | 62 +++----- uv.lock | 279 +---------------------------------- 8 files changed, 45 insertions(+), 352 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8789b586e..104cda66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" diff --git a/AGENTS.md b/AGENTS.md index 1d82ae5dd..851b4e8d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Minimum verification matrix: The main CI workflow currently runs: - linting on Python 3.13 - mypy on Python 3.13 -- `tests/unit` on a Python 3.10-3.14 matrix +- `tests/unit` on a Python 3.11-3.14 matrix - `tests/e2e` in 2 mechanical shards plus a serial subset inside each shard - `tests/live_provider` as one always-on suite - PR title validation for Conventional Commits diff --git a/langfuse/_client/observe.py b/langfuse/_client/observe.py index 848506a46..5aa88ce2e 100644 --- a/langfuse/_client/observe.py +++ b/langfuse/_client/observe.py @@ -2,7 +2,6 @@ import contextvars import inspect import os -import sys from functools import wraps from typing import ( Any, @@ -49,7 +48,16 @@ P = ParamSpec("P") R = TypeVar("R") -_ASYNCIO_CREATE_TASK_SUPPORTS_CONTEXT = sys.version_info >= (3, 11) +# Set by asgiref's markcoroutinefunction on Python < 3.12, which +# inspect.iscoroutinefunction does not recognize. +_ASYNCIO_COROUTINE_MARKER = getattr(asyncio.coroutines, "_is_coroutine", None) + + +def _is_coroutine_function(func: Any) -> bool: + return inspect.iscoroutinefunction(func) or ( + _ASYNCIO_COROUTINE_MARKER is not None + and getattr(func, "_is_coroutine", None) is _ASYNCIO_COROUTINE_MARKER + ) class LangfuseDecorator: @@ -217,7 +225,7 @@ def decorator(func: F) -> F: capture_output=should_capture_output, transform_to_string=transform_to_string, ) - if asyncio.iscoroutinefunction(func) + if _is_coroutine_function(func) else self._sync_observe( func, name=name, @@ -747,15 +755,7 @@ async def aclose(self) -> None: self._finalize() async def _close_generator(self) -> None: - if _ASYNCIO_CREATE_TASK_SUPPORTS_CONTEXT: - close_task = asyncio.create_task( - self.generator.aclose(), - context=self.context, - ) # type: ignore - else: - close_task = self.context.run(asyncio.create_task, self.generator.aclose()) - - await close_task + await asyncio.create_task(self.generator.aclose(), context=self.context) async def close(self) -> None: await self.aclose() @@ -769,16 +769,10 @@ def __del__(self) -> None: async def __anext__(self) -> Any: try: # Run the generator's __anext__ in the preserved context - if _ASYNCIO_CREATE_TASK_SUPPORTS_CONTEXT: - item = await asyncio.create_task( - self.generator.__anext__(), # type: ignore - context=self.context, - ) # type: ignore - else: - item = await self.context.run( - asyncio.create_task, - self.generator.__anext__(), # type: ignore - ) + item = await asyncio.create_task( + self.generator.__anext__(), # type: ignore + context=self.context, + ) if self.capture_output: self.items.append(item) diff --git a/langfuse/types.py b/langfuse/types.py index 66bddbca6..d20bd6a55 100644 --- a/langfuse/types.py +++ b/langfuse/types.py @@ -24,6 +24,7 @@ def my_evaluator(*, output: str, **kwargs) -> Evaluation: Dict, Literal, Mapping, + NotRequired, Optional, Protocol, Sequence, @@ -31,12 +32,6 @@ def my_evaluator(*, output: str, **kwargs) -> Evaluation: Union, ) -try: - from typing import NotRequired # type: ignore -except ImportError: - from typing_extensions import NotRequired - - from langfuse.api import MediaContentType # Span attribute values accepted by the OpenTelemetry trace API. OpenTelemetry 1.45 diff --git a/pyproject.toml b/pyproject.toml index 60e73a9ec..1534e4aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "langfuse", email = "developers@langfuse.com" }] license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10,<4.0" +requires-python = ">=3.11,<4.0" keywords = [ "langfuse", "llm", @@ -139,7 +139,7 @@ module = "langfuse.api.*" disable_error_code = ["redundant-cast", "no-untyped-def"] [tool.ruff] -target-version = "py310" +target-version = "py311" [tool.ruff.lint] extend-select = [ diff --git a/tests/e2e/test_decorators.py b/tests/e2e/test_decorators.py index 9e302f799..9fac4cf22 100644 --- a/tests/e2e/test_decorators.py +++ b/tests/e2e/test_decorators.py @@ -1,6 +1,5 @@ import asyncio import os -import sys from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from time import sleep @@ -1880,7 +1879,6 @@ def root_function(): @pytest.mark.asyncio -@pytest.mark.skipif(sys.version_info < (3, 11), reason="requires python3.11 or higher") async def test_async_generator_context_preservation(): """Test that async generators preserve context when consumed later (e.g., by streaming responses)""" langfuse = get_client() @@ -1946,7 +1944,6 @@ async def root_function(): @pytest.mark.asyncio -@pytest.mark.skipif(sys.version_info < (3, 11), reason="requires python3.11 or higher") async def test_async_generator_context_preservation_with_trace_hierarchy(): """Test that async generators maintain proper parent-child span relationships""" langfuse = get_client() @@ -2009,7 +2006,6 @@ async def parent_function(): @pytest.mark.asyncio -@pytest.mark.skipif(sys.version_info < (3, 11), reason="requires python3.11 or higher") async def test_async_generator_exception_handling_with_context(): """Test that exceptions in async generators are properly handled while preserving context""" langfuse = get_client() diff --git a/tests/unit/test_observe.py b/tests/unit/test_observe.py index f2ff11789..21ff1418d 100644 --- a/tests/unit/test_observe.py +++ b/tests/unit/test_observe.py @@ -3,13 +3,11 @@ import gc import inspect import json -import sys from typing import Any, AsyncGenerator, Generator, cast import pytest from langfuse import observe -from langfuse._client import observe as observe_module from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.observe import ( _ContextPreservedAsyncGeneratorWrapper, @@ -96,7 +94,6 @@ def body() -> Generator[str, None, None]: @pytest.mark.asyncio -@pytest.mark.skipif(sys.version_info < (3, 11), reason="requires python3.11 or higher") async def test_streaming_response_preserves_context_without_output_capture( langfuse_memory_client: Any, memory_exporter: Any ) -> None: @@ -479,41 +476,6 @@ async def generator() -> AsyncGenerator[str, None]: assert span.updates[-1] == {"level": "ERROR", "status_message": "cleanup failed"} -@pytest.mark.asyncio -async def test_async_generator_wrapper_fallback_preserves_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - marker = contextvars.ContextVar("marker", default="ambient") - seen: list[str] = [] - monkeypatch.setattr(observe_module, "_ASYNCIO_CREATE_TASK_SUPPORTS_CONTEXT", False) - - async def generator() -> AsyncGenerator[str, None]: - try: - yield marker.get() - yield "item_1" - finally: - seen.append(marker.get()) - - span = SpanRecorder() - context = contextvars.copy_context() - context.run(marker.set, "preserved") - wrapper = _ContextPreservedAsyncGeneratorWrapper( - generator(), - context, - cast(Any, span), - False, - None, - ) - - assert await wrapper.__anext__() == "preserved" - marker.set("ambient-now") - - await wrapper.aclose() - - assert seen == ["preserved"] - assert span.ended == 1 - - @pytest.mark.asyncio async def test_async_generator_wrapper_del_ends_span_when_abandoned() -> None: async def generator() -> AsyncGenerator[str, None]: @@ -537,3 +499,27 @@ async def generator() -> AsyncGenerator[str, None]: assert span.ended == 1 assert span.updates == [] + + +@pytest.mark.asyncio +async def test_observe_treats_legacy_marked_coroutine_function_as_async( + langfuse_memory_client: Any, memory_exporter: Any +) -> None: + async def work() -> str: + await asyncio.sleep(0) + return "done" + + def marked() -> Any: + return work() + + # The marker asgiref's markcoroutinefunction sets on Python < 3.12. + cast(Any, marked)._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore[attr-defined] + + observed = observe(name="marked")(marked) + + assert await observed() == "done" + + langfuse_memory_client.flush() + + span = _finished_spans_by_name(memory_exporter, "marked")[0] + assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "done" diff --git a/uv.lock b/uv.lock index a71f89727..ecacc3a0d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10, <4.0" +requires-python = ">=3.11, <4.0" [options] exclude-newer = "2026-09-28T08:53:26.348016113Z" @@ -32,7 +32,6 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -74,15 +73,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -107,22 +97,6 @@ version = "3.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, @@ -242,18 +216,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "execnet" version = "2.1.2" @@ -366,18 +328,6 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, - { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, - { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, - { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, @@ -705,18 +655,6 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, @@ -790,17 +728,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -877,17 +804,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, @@ -1098,19 +1018,6 @@ version = "3.11.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, - { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, - { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, - { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, - { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, - { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, @@ -1179,14 +1086,6 @@ version = "1.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, - { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, - { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, - { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, @@ -1285,15 +1184,6 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/91/c7/e0b3bbe72e0003e5d02726e0d406ea47d523a2aec9c41d831817a8e0bce1/polyleven-0.11.0.tar.gz", hash = "sha256:d74d348387cf340051711c0dd6af993b4c264daa78470098de16f4a2b725785c", size = 6407, upload-time = "2026-02-09T09:41:49.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/6c/34c6189c80adf7575fb2daee38c4b836c154e416ab3c14d17afa7f88b9c3/polyleven-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ccf87f6ac8d76aa4c48a4828becc0c19fd1589b14b20affe23e5e012be4fa64f", size = 7421, upload-time = "2026-02-09T09:40:33.243Z" }, - { url = "https://files.pythonhosted.org/packages/e6/21/b20d3c9f9b6bded43a0388037044f2bcb1add20fa9a758d1144d79e09d10/polyleven-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1a02e3f0acfd1164cbaea25192398bc943ee9b93b9883a1fba9b2613d3616b0", size = 7514, upload-time = "2026-02-09T09:40:34.514Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d8/60290fd8d8298671edb4ce221d8ee4d81156b3c21b1154a615da7ea8e57d/polyleven-0.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6526d2516b439065864722069de6fcc418a4135696990dad66b81ddb18863bd9", size = 19556, upload-time = "2026-02-09T09:40:35.868Z" }, - { url = "https://files.pythonhosted.org/packages/c1/cd/17f1f6009a344c18f4213edcc97a9e7dae22e9a26e722aae10052378a43e/polyleven-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65fc01fe6cfe287f2f20170b35687a436ab36b882db568a55d81d6e0acd8379d", size = 20303, upload-time = "2026-02-09T09:40:36.99Z" }, - { url = "https://files.pythonhosted.org/packages/4e/18/30c7da8056adc4b9a81775f5b1810a2c9b6cc87fc34c8cf4c0280d28cab6/polyleven-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4b8e5ac8faecc6daa7b3d325436a3f23f8c33dec7bfca5d22df3fbe00f92ddd9", size = 19565, upload-time = "2026-02-09T09:40:38.471Z" }, - { url = "https://files.pythonhosted.org/packages/de/14/86e9c33ff9fda84297556373a6376100cbe9bb5d917fc3421dce4ada441b/polyleven-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4f17007b07fde292ad33ec43a3ae8febe27a5bd92462b920736fd81d774fce", size = 19365, upload-time = "2026-02-09T09:40:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/be/18/1341f7860bbe2287f6cb8a540c3435c504cfc58659b67106ab60f695175e/polyleven-0.11.0-cp310-cp310-win32.whl", hash = "sha256:cae70197d545a09bfab8d7e506eed66ef314fa6c4e7a5e2c402c2febc31db74b", size = 11672, upload-time = "2026-02-09T09:40:40.822Z" }, - { url = "https://files.pythonhosted.org/packages/6d/09/ed2cb3dbec7a925d80107516b06dfe10dd368c6abfb43765df6feb6cd551/polyleven-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:bf47079b6dc62e6af2bd6ecb45a6087efd9a27b61666b98d0326c246a22ea991", size = 10828, upload-time = "2026-02-09T09:40:42.224Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/cb74c2ffe4e35935d80ef6f180f9e0987b8000917d52050a3563b32e1e73/polyleven-0.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:4c78b4d3e7d7b74315d5422178118963374c0cf3d7a9532a955f446ed365320a", size = 9389, upload-time = "2026-02-09T09:40:43.172Z" }, { url = "https://files.pythonhosted.org/packages/6c/7a/27ea9a78b617ddb14c2f5d2416df2fbf07fa5e52685f2968686a0308c8af/polyleven-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a28860fe33a7f907bc5f86e55a0b9faea80047d1677fa23b4d6c631ccf91ef2f", size = 7420, upload-time = "2026-02-09T09:40:44.505Z" }, { url = "https://files.pythonhosted.org/packages/a6/9c/fea309d41502aa5a344a6d4d6e5b8bdabb1df1e28f1af52bb53180f6c956/polyleven-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47a3fb5b8cb60f647d2832d38b7d87cda27da8622b27c1292bceb9a04954c189", size = 7514, upload-time = "2026-02-09T09:40:46.44Z" }, { url = "https://files.pythonhosted.org/packages/48/77/c7d3bb6c66050304c3fe3cae1a716f62fea947ac3f14d02ef71e24422f76/polyleven-0.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:209fa669ca23ac453a7e9fbf07695350d5cbe61d71a6226b861757ccab28e664", size = 20887, upload-time = "2026-02-09T09:40:47.304Z" }, @@ -1396,19 +1286,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, @@ -1487,14 +1364,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, @@ -1520,12 +1389,10 @@ version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1537,7 +1404,6 @@ name = "pytest-asyncio" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/1e/2aa43805d4a320a9489d2b99f7877b69f9094c79aa0732159a1415dd6cd4/pytest_asyncio-1.1.1.tar.gz", hash = "sha256:b72d215c38e2c91dbb32f275e0b5be69602d7869910e109360e375129960a649", size = 46590, upload-time = "2025-09-12T06:36:20.834Z" } @@ -1601,15 +1467,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1679,23 +1536,6 @@ version = "2026.2.28" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, - { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, - { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, - { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, - { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, - { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, - { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, - { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, - { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, - { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, - { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, - { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, - { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, @@ -1827,20 +1667,6 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, @@ -1996,13 +1822,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, - { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, - { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, @@ -2047,60 +1866,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - [[package]] name = "tqdm" version = "4.67.3" @@ -2181,7 +1946,6 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ @@ -2206,16 +1970,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, @@ -2275,21 +2029,6 @@ version = "3.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, - { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, - { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, - { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, - { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, - { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, - { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, @@ -2393,22 +2132,6 @@ version = "0.25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, - { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, - { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, - { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, - { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, - { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, From 5aae390428a1865dcaa73caf55890fdeedcfd943 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:59:44 +0400 Subject: [PATCH 02/31] feat(openai)!: drop support for OpenAI SDK versions below 1.0 (#1928) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/openai.py | 63 +++++++++------------------------------------- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 14 insertions(+), 53 deletions(-) diff --git a/langfuse/openai.py b/langfuse/openai.py index 16e7f1c1f..d3758ecb5 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -80,24 +80,6 @@ class OpenAiDefinition: max_version: Optional[str] = None -OPENAI_METHODS_V0 = [ - OpenAiDefinition( - module="openai", - object="ChatCompletion", - method="create", - type="chat", - sync=True, - ), - OpenAiDefinition( - module="openai", - object="Completion", - method="create", - type="completion", - sync=True, - ), -] - - OPENAI_METHODS_V1 = [ OpenAiDefinition( module="openai.resources.chat.completions", @@ -798,8 +780,7 @@ def _extract_streamed_openai_response(resource: Any, chunks: Any) -> Any: model, usage, finish_reason, service_tier = None, None, None, None for chunk in chunks: - if _is_openai_v1(): - chunk = chunk.__dict__ + chunk = chunk.__dict__ model = model or chunk.get("model", None) or None service_tier = service_tier or chunk.get("service_tier", None) or None @@ -810,15 +791,14 @@ def _extract_streamed_openai_response(resource: Any, chunks: Any) -> Any: choices = chunk.get("choices") or [] for choice in choices: - if _is_openai_v1(): - choice = choice.__dict__ + choice = choice.__dict__ if resource.type == "chat": delta = choice.get("delta", None) choice_finish_reason = choice.get("finish_reason", None) if choice_finish_reason is not None: finish_reason = choice_finish_reason - if _is_openai_v1() and delta is not None: + if delta is not None: delta = delta.__dict__ if delta is None: @@ -956,7 +936,7 @@ def _get_langfuse_data_from_default_response( if len(choices) > 0: choice = choices[-1] - completion = choice.text if _is_openai_v1() else choice.get("text", None) + completion = choice.text elif resource.object == "Responses" or resource.object == "AsyncResponses": completion = _extract_response_api_completion(response.get("output", {})) @@ -968,17 +948,11 @@ def _get_langfuse_data_from_default_response( if len(choices) > 1: completion = [ _extract_chat_response(choice.message.__dict__) - if _is_openai_v1() - else choice.get("message", None) for choice in choices ] else: choice = choices[0] - completion = ( - _extract_chat_response(choice.message.__dict__) - if _is_openai_v1() - else choice.get("message", None) - ) + completion = _extract_chat_response(choice.message.__dict__) elif resource.type == "embedding": data = response.get("data") or [] @@ -1015,16 +989,12 @@ def _merge_service_tier_into_model_parameters( return {**(model_parameters or {}), "service_tier": service_tier} -def _is_openai_v1() -> bool: - return Version(openai.__version__) >= Version("1.0.0") - - def _is_streaming_response(response: Any) -> bool: return ( isinstance(response, types.GeneratorType) or isinstance(response, types.AsyncGeneratorType) - or (_is_openai_v1() and isinstance(response, openai.Stream)) - or (_is_openai_v1() and isinstance(response, openai.AsyncStream)) + or isinstance(response, openai.Stream) + or isinstance(response, openai.AsyncStream) ) @@ -1034,9 +1004,6 @@ def _is_streaming_response(response: Any) -> bool: def _install_openai_stream_iteration_hooks() -> None: global _openai_stream_iter_hook_installed - if not _is_openai_v1(): - return - if not _openai_stream_iter_hook_installed: original_iter = openai.Stream.__iter__ original_aiter = openai.AsyncStream.__aiter__ @@ -1318,7 +1285,7 @@ def _wrap( try: openai_response = wrapped(**arg_extractor.get_openai_args()) - if _is_openai_v1() and isinstance(openai_response, openai.Stream): + if isinstance(openai_response, openai.Stream): return _instrument_openai_stream( resource=open_ai_resource, response=openai_response, @@ -1338,9 +1305,7 @@ def _wrap( model, completion, usage, service_tier = ( _get_langfuse_data_from_default_response( open_ai_resource, - (parsed_response and parsed_response.__dict__) - if _is_openai_v1() - else parsed_response, + parsed_response and parsed_response.__dict__, ) ) @@ -1407,7 +1372,7 @@ async def _wrap_async( try: openai_response = await wrapped(**arg_extractor.get_openai_args()) - if _is_openai_v1() and isinstance(openai_response, openai.AsyncStream): + if isinstance(openai_response, openai.AsyncStream): return _instrument_openai_async_stream( resource=open_ai_resource, response=openai_response, @@ -1427,9 +1392,7 @@ async def _wrap_async( model, completion, usage, service_tier = ( _get_langfuse_data_from_default_response( open_ai_resource, - (parsed_response and parsed_response.__dict__) - if _is_openai_v1() - else parsed_response, + parsed_response and parsed_response.__dict__, ) ) generation.update( @@ -1460,9 +1423,7 @@ async def _wrap_async( def register_tracing() -> None: - resources = OPENAI_METHODS_V1 if _is_openai_v1() else OPENAI_METHODS_V0 - - for resource in resources: + for resource in OPENAI_METHODS_V1: if resource.min_version is not None and Version(openai.__version__) < Version( resource.min_version ): diff --git a/pyproject.toml b/pyproject.toml index 1534e4aa6..743bc32e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dev = [ "pytest-httpserver>=1.0.8,<2", "ruff>=0.15.2,<0.16", "mypy>=1.0.0,<2", - "openai>=0.27.8", + "openai>=1.0.0", "langchain-openai>=0.0.5,<0.4", "langchain>=1,<2", "langgraph>=1,<2", diff --git a/uv.lock b/uv.lock index ecacc3a0d..701ea859a 100644 --- a/uv.lock +++ b/uv.lock @@ -560,7 +560,7 @@ dev = [ { name = "langchain-openai", specifier = ">=0.0.5,<0.4" }, { name = "langgraph", specifier = ">=1,<2" }, { name = "mypy", specifier = ">=1.0.0,<2" }, - { name = "openai", specifier = ">=0.27.8" }, + { name = "openai", specifier = ">=1.0.0" }, { name = "opentelemetry-instrumentation-threading", specifier = ">=0.59b0,<1" }, { name = "pre-commit", specifier = ">=3.2.2,<4" }, { name = "pytest", specifier = ">=7.4,<9.0" }, From 10e48497d95e7b72bed51be76fa13454cc19a5f3 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:27:18 +0400 Subject: [PATCH 03/31] feat(otel)!: send x-langfuse-ingestion-version: 4 by default (#1930) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/span_processor.py | 1 + tests/unit/test_additional_headers_simple.py | 35 ++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index b362b8da1..89b4f5bd6 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -159,6 +159,7 @@ def __init__( "x-langfuse-sdk-name": "python", "x-langfuse-sdk-version": langfuse_version, "x-langfuse-public-key": public_key, + "x-langfuse-ingestion-version": "4", } # Merge additional headers if provided diff --git a/tests/unit/test_additional_headers_simple.py b/tests/unit/test_additional_headers_simple.py index 7837c5c3e..d05475a3c 100644 --- a/tests/unit/test_additional_headers_simple.py +++ b/tests/unit/test_additional_headers_simple.py @@ -210,6 +210,41 @@ def test_span_processor_none_additional_headers_works(self): assert "authorization" in exporter._client._headers assert "x-langfuse-sdk-name" in exporter._client._headers assert "x-langfuse-public-key" in exporter._client._headers + assert exporter._client._headers["x-langfuse-ingestion-version"] == "4" + + def test_span_processor_additional_headers_override_ingestion_version(self): + """Test that additional headers can override the default ingestion version.""" + from langfuse._client.span_processor import LangfuseSpanProcessor + + processor = LangfuseSpanProcessor( + public_key="test-public-key", + secret_key="test-secret-key", + base_url="https://mock-host.com", + additional_headers={"x-langfuse-ingestion-version": "3"}, + ) + + exporter = processor.span_exporter + + assert exporter._client._headers["x-langfuse-ingestion-version"] == "3" + + def test_span_processor_mixed_case_override_replaces_ingestion_version(self): + """Test that a differently cased override replaces the default header.""" + from langfuse._client.span_processor import LangfuseSpanProcessor + + processor = LangfuseSpanProcessor( + public_key="test-public-key", + secret_key="test-secret-key", + base_url="https://mock-host.com", + additional_headers={"X-Langfuse-Ingestion-Version": "3"}, + ) + + ingestion_headers = { + key: value + for key, value in processor.span_exporter._client._headers.items() + if key.lower() == "x-langfuse-ingestion-version" + } + + assert ingestion_headers == {"x-langfuse-ingestion-version": "3"} def test_span_processor_uses_custom_span_exporter_when_provided(self): """Test that a custom exporter bypasses the default OTLP exporter construction.""" From 049a6da0054a6068a69534aaaaf558e680ca08fb Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:12 +0400 Subject: [PATCH 04/31] feat(client)!: remove blocked_instrumentation_scopes (#1931) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * feat(client)!: remove blocked_instrumentation_scopes blocked_instrumentation_scopes was deprecated in favor of should_export_span. Remove it from Langfuse, the resource manager and the span processor, and drop the leftover is_active entry from the create_prompt docstring. LANGFUSE_HOST and host keep working, but now log a deprecation warning when they decide the base URL. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * feat(client): keep host and LANGFUSE_HOST working without a warning Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/client.py | 29 ----------- langfuse/_client/get_client.py | 1 - langfuse/_client/resource_manager.py | 5 -- langfuse/_client/span_processor.py | 27 +--------- tests/conftest.py | 4 -- tests/unit/test_app_root_detection.py | 7 ++- tests/unit/test_initialization.py | 10 ++++ tests/unit/test_otel.py | 73 +-------------------------- 8 files changed, 17 insertions(+), 139 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index af06e69ef..10b9b52e4 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -9,7 +9,6 @@ import re import urllib.parse import uuid -import warnings from datetime import datetime from hashlib import sha256 from time import time_ns @@ -251,19 +250,6 @@ def mask_otel_spans( langfuse = Langfuse(mask_otel_spans=mask_otel_spans) ``` - blocked_instrumentation_scopes (Optional[List[str]]): Deprecated. Use `should_export_span` instead. Equivalent behavior: - ```python - from langfuse.span_filter import is_default_export_span - blocked = {"sqlite", "requests"} - - should_export_span = lambda span: ( - is_default_export_span(span) - and ( - span.instrumentation_scope is None - or span.instrumentation_scope.name not in blocked - ) - ) - ``` should_export_span (Optional[Callable[[ReadableSpan], bool]]): Callback to decide whether to export a span. If omitted, Langfuse uses the default filter (Langfuse SDK spans, spans with `gen_ai.*` attributes, and known LLM instrumentation scopes). additional_headers (Optional[Dict[str, str]]): Additional headers to include in all API requests and in the default OTLPSpanExporter requests. These headers will be merged with default headers. Note: If httpx_client is provided, additional_headers must be set directly on your custom httpx_client as well. If `span_exporter` is provided, these headers are not wired into that exporter and must be configured on the exporter instance directly. tracer_provider(Optional[TracerProvider]): OpenTelemetry TracerProvider to use for Langfuse. This can be useful to set to have disconnected tracing between Langfuse and other OpenTelemetry-span emitting libraries. Note: To track active spans, the context is still shared between TracerProviders. This may lead to broken trace trees. @@ -330,7 +316,6 @@ def __init__( sample_rate: Optional[float] = None, mask: Optional[MaskFunction] = None, mask_otel_spans: Optional[MaskOtelSpansFunction] = None, - blocked_instrumentation_scopes: Optional[List[str]] = None, should_export_span: Optional[Callable[[ReadableSpan], bool]] = None, additional_headers: Optional[Dict[str, str]] = None, tracer_provider: Optional[TracerProvider] = None, @@ -403,18 +388,6 @@ def __init__( "OTEL_SDK_DISABLED is set. Langfuse tracing will be disabled and no traces will appear in the UI." ) - if blocked_instrumentation_scopes is not None: - warnings.warn( - "`blocked_instrumentation_scopes` is deprecated and will be removed in a future release. " - "Use `should_export_span` instead. Example: " - "from langfuse.span_filter import is_default_export_span; " - 'blocked={"scope"}; should_export_span=lambda span: ' - "is_default_export_span(span) and (span.instrumentation_scope is None or " - "span.instrumentation_scope.name not in blocked).", - DeprecationWarning, - stacklevel=2, - ) - # Initialize api and tracer if requirements are met self._resources = LangfuseResourceManager( public_key=public_key, @@ -431,7 +404,6 @@ def __init__( mask=mask, mask_otel_spans=mask_otel_spans, tracing_enabled=self._tracing_enabled, - blocked_instrumentation_scopes=blocked_instrumentation_scopes, should_export_span=should_export_span, additional_headers=additional_headers, tracer_provider=tracer_provider, @@ -4137,7 +4109,6 @@ def create_prompt( Keyword Args: name : The name of the prompt to be created. prompt : The content of the prompt to be created. - is_active [DEPRECATED] : A flag indicating whether the prompt is active or not. This is deprecated and will be removed in a future release. Please use the 'production' label instead. labels: The labels of the prompt. Defaults to None. To create a default-served prompt, add the 'production' label. tags: The tags of the prompt. Defaults to None. Will be applied to all versions of the prompt. config: Additional structured data to be saved with the prompt. Defaults to None. diff --git a/langfuse/_client/get_client.py b/langfuse/_client/get_client.py index ff06c7d29..eceaed5ef 100644 --- a/langfuse/_client/get_client.py +++ b/langfuse/_client/get_client.py @@ -51,7 +51,6 @@ def _create_client_from_instance( sample_rate=instance.sample_rate, mask=instance.mask, mask_otel_spans=instance.mask_otel_spans, - blocked_instrumentation_scopes=instance.blocked_instrumentation_scopes, should_export_span=instance.should_export_span, additional_headers=instance.additional_headers, tracer_provider=instance.tracer_provider, diff --git a/langfuse/_client/resource_manager.py b/langfuse/_client/resource_manager.py index 4395872db..e1e091d31 100644 --- a/langfuse/_client/resource_manager.py +++ b/langfuse/_client/resource_manager.py @@ -127,7 +127,6 @@ def __new__( mask: Optional[MaskFunction] = None, mask_otel_spans: Optional[MaskOtelSpansFunction] = None, tracing_enabled: Optional[bool] = None, - blocked_instrumentation_scopes: Optional[List[str]] = None, should_export_span: Optional[Callable[[ReadableSpan], bool]] = None, additional_headers: Optional[Dict[str, str]] = None, tracer_provider: Optional[TracerProvider] = None, @@ -166,7 +165,6 @@ def __new__( tracing_enabled=tracing_enabled if tracing_enabled is not None else True, - blocked_instrumentation_scopes=blocked_instrumentation_scopes, should_export_span=should_export_span, additional_headers=additional_headers, tracer_provider=tracer_provider, @@ -196,7 +194,6 @@ def _initialize_instance( mask: Optional[MaskFunction] = None, mask_otel_spans: Optional[MaskOtelSpansFunction] = None, tracing_enabled: bool = True, - blocked_instrumentation_scopes: Optional[List[str]] = None, should_export_span: Optional[Callable[[ReadableSpan], bool]] = None, additional_headers: Optional[Dict[str, str]] = None, tracer_provider: Optional[TracerProvider] = None, @@ -220,7 +217,6 @@ def _initialize_instance( self.release = release self.media_upload_thread_count = media_upload_thread_count self.sample_rate = sample_rate - self.blocked_instrumentation_scopes = blocked_instrumentation_scopes self.should_export_span = should_export_span self.additional_headers = additional_headers self.id_generator = id_generator @@ -259,7 +255,6 @@ def _initialize_instance( timeout=timeout, flush_at=flush_at, flush_interval=flush_interval, - blocked_instrumentation_scopes=blocked_instrumentation_scopes, should_export_span=should_export_span, additional_headers=additional_headers, span_exporter=span_exporter, diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index 89b4f5bd6..ba2f5b2de 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -15,7 +15,7 @@ import logging import os import threading -from typing import Callable, Dict, List, Literal, Optional, cast +from typing import Callable, Dict, Literal, Optional, cast from opentelemetry import context as context_api from opentelemetry.context import Context @@ -121,7 +121,6 @@ def __init__( timeout: Optional[int] = None, flush_at: Optional[int] = None, flush_interval: Optional[float] = None, - blocked_instrumentation_scopes: Optional[List[str]] = None, should_export_span: Optional[Callable[[ReadableSpan], bool]] = None, additional_headers: Optional[Dict[str, str]] = None, span_exporter: Optional[SpanExporter] = None, @@ -130,11 +129,6 @@ def __init__( otel_compression: Optional[Literal["gzip", "none"]] = None, ): self.public_key = public_key - self.blocked_instrumentation_scopes = ( - blocked_instrumentation_scopes - if blocked_instrumentation_scopes is not None - else [] - ) self._should_export_span = should_export_span or is_default_export_span self._app_root_lock = threading.Lock() @@ -258,16 +252,6 @@ def on_end(self, span: ReadableSpan) -> None: ) return - # Do not export spans from blocked instrumentation scopes - if self._is_blocked_instrumentation_scope(span): - langfuse_logger.debug( - "Trace: Dropping span due to blocked instrumentation scope | " - "span_name='%s' | instrumentation_scope='%s'", - span.name, - self._get_scope_name(span), - ) - return - # Apply custom or default span filter try: should_export = self._should_export_span(span) @@ -342,9 +326,6 @@ def _is_expected_exported_at_start(self, span: Span) -> bool: ): return False - if self._is_blocked_instrumentation_scope(readable_span): - return False - try: return bool(self._should_export_span(readable_span)) except Exception as error: @@ -359,12 +340,6 @@ def _is_expected_exported_at_start(self, span: Span) -> bool: return False - def _is_blocked_instrumentation_scope(self, span: ReadableSpan) -> bool: - return ( - span.instrumentation_scope is not None - and span.instrumentation_scope.name in self.blocked_instrumentation_scopes - ) - def _is_langfuse_project_span(self, span: ReadableSpan) -> bool: if not is_langfuse_span(span): return False diff --git a/tests/conftest.py b/tests/conftest.py index 0163842c6..97a78d212 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,10 +102,6 @@ def mock_init(self: Any, **kwargs: Any) -> None: from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-public-key") - blocked_scopes = kwargs.get("blocked_instrumentation_scopes") - self.blocked_instrumentation_scopes = ( - blocked_scopes if blocked_scopes is not None else [] - ) self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) diff --git a/tests/unit/test_app_root_detection.py b/tests/unit/test_app_root_detection.py index 7fa346d1e..fdf19639a 100644 --- a/tests/unit/test_app_root_detection.py +++ b/tests/unit/test_app_root_detection.py @@ -252,12 +252,15 @@ def test_active_langfuse_scope_sets_baggage_after_root_start( assert "langfuse.trace.metadata.trace_id" not in spans["child"].attributes -def test_blocked_instrumentation_scope_parent_marks_child_as_app_root( +def test_filtered_scope_parent_marks_child_as_app_root( memory_exporter, ): tracer_provider, processor = _create_processor( memory_exporter, - blocked_instrumentation_scopes=["blocked.scope"], + should_export_span=lambda span: ( + span.instrumentation_scope is None + or span.instrumentation_scope.name != "blocked.scope" + ), ) blocked_tracer = tracer_provider.get_tracer("blocked.scope") langfuse_tracer = _langfuse_tracer(tracer_provider) diff --git a/tests/unit/test_initialization.py b/tests/unit/test_initialization.py index 7181ae45e..1f682d3fe 100644 --- a/tests/unit/test_initialization.py +++ b/tests/unit/test_initialization.py @@ -100,6 +100,16 @@ def test_env_host_fallback(self, cleanup_env_vars): assert client._base_url == "http://env-host.com" + def test_env_host_fallback_logs_no_warning(self, cleanup_env_vars, caplog): + """Test that LANGFUSE_HOST keeps working without a deprecation warning.""" + caplog.set_level("WARNING", logger="langfuse") + os.environ["LANGFUSE_HOST"] = "http://env-host.com" + + client = Langfuse(public_key="test_pk", secret_key="test_sk") + + assert client._base_url == "http://env-host.com" + assert not any("deprecated" in record.message for record in caplog.records) + def test_default_base_url(self, cleanup_env_vars): """Test that default base_url is used when nothing is set.""" client = Langfuse( diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 46a085a71..ccdec39fd 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -120,10 +120,6 @@ def mock_init(self, **kwargs): from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-key") - blocked_scopes = kwargs.get("blocked_instrumentation_scopes") - self.blocked_instrumentation_scopes = ( - blocked_scopes if blocked_scopes is not None else [] - ) self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) @@ -2408,10 +2404,6 @@ def mock_processor_init(self, **kwargs): from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-key") - blocked_scopes = kwargs.get("blocked_instrumentation_scopes") - self.blocked_instrumentation_scopes = ( - blocked_scopes if blocked_scopes is not None else [] - ) self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) @@ -2446,16 +2438,13 @@ def mock_initialize(self, **kwargs): # Call original_initialize to set up all the necessary attributes original_initialize(self, **kwargs) - # Now create our custom LangfuseSpanProcessor with the actual blocked_instrumentation_scopes + # Now create our custom LangfuseSpanProcessor with the actual should_export_span from langfuse._client.span_processor import LangfuseSpanProcessor processor = LangfuseSpanProcessor( public_key=self.public_key, secret_key=self.secret_key, base_url=self.base_url, - blocked_instrumentation_scopes=kwargs.get( - "blocked_instrumentation_scopes" - ), should_export_span=kwargs.get("should_export_span"), ) # Replace its exporter with our test exporter @@ -2661,38 +2650,6 @@ def test_custom_should_export_span_with_composition( assert "known-span" in exported_span_names assert "unknown-span" not in exported_span_names - def test_blocked_scopes_override_should_export( - self, instrumentation_filtering_setup - ): - """Test that blocked scopes are dropped even when callback allows all.""" - with pytest.warns(DeprecationWarning, match="blocked_instrumentation_scopes"): - Langfuse( - public_key=instrumentation_filtering_setup["test_key"], - secret_key="test-secret-key", - base_url="http://localhost:3000", - blocked_instrumentation_scopes=["my-framework.worker"], - should_export_span=lambda span: True, - ) - - tracer_provider = instrumentation_filtering_setup["test_tracer_provider"] - blocked_tracer = tracer_provider.get_tracer("my-framework.worker") - allowed_tracer = tracer_provider.get_tracer("custom.allowed") - - blocked_span = blocked_tracer.start_span("blocked-span") - blocked_span.end() - allowed_span = allowed_tracer.start_span("allowed-span") - allowed_span.end() - tracer_provider.force_flush() - - exported_span_names = [ - span.name - for span in instrumentation_filtering_setup[ - "blocked_exporter" - ].get_finished_spans() - ] - assert "blocked-span" not in exported_span_names - assert "allowed-span" in exported_span_names - def test_should_export_span_with_none_uses_default( self, instrumentation_filtering_setup ): @@ -2758,34 +2715,6 @@ def _failing_filter(_span): for record in caplog.records ) - def test_blocked_scope_drop_logs_scope_name( - self, instrumentation_filtering_setup, caplog - ): - """Test that blocked scope drops include scope names in debug logs.""" - caplog.set_level("DEBUG", logger="langfuse") - - with pytest.warns(DeprecationWarning, match="blocked_instrumentation_scopes"): - Langfuse( - public_key=instrumentation_filtering_setup["test_key"], - secret_key="test-secret-key", - base_url="http://localhost:3000", - blocked_instrumentation_scopes=["my.blocked.scope"], - should_export_span=lambda span: True, - ) - - tracer_provider = instrumentation_filtering_setup["test_tracer_provider"] - blocked_tracer = tracer_provider.get_tracer("my.blocked.scope") - - span = blocked_tracer.start_span("blocked-debug-span") - span.end() - tracer_provider.force_flush() - - assert any( - "Dropping span due to blocked instrumentation scope" in record.message - and "my.blocked.scope" in record.message - for record in caplog.records - ) - class TestConcurrencyAndAsync(TestOTelBase): """Tests for asynchronous and concurrent span operations.""" From 8d7c066629ada56ff367b82fb2d8be3554a9481f Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:38:48 +0400 Subject: [PATCH 05/31] feat(tracing)!: JSON-serialize propagated metadata values (#1932) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * feat(client)!: remove blocked_instrumentation_scopes blocked_instrumentation_scopes was deprecated in favor of should_export_span. Remove it from Langfuse, the resource manager and the span processor, and drop the leftover is_active entry from the create_prompt docstring. LANGFUSE_HOST and host keep working, but now log a deprecation warning when they decide the base URL. Co-authored-by: Hassieb Pakzad * feat(tracing)!: JSON-serialize propagated metadata values propagate_attributes(metadata=...) coerced non-string values with str(), so a boolean became "True" and a list became its Python repr. Serialize them as JSON instead, like observation metadata, and drop None values instead of sending "None". Co-authored-by: Hassieb Pakzad * fix(tracing): serialize propagated metadata like JSON.stringify Use compact separators, keep non-ASCII characters, and send None as "null", so the Python and JS SDKs emit byte-identical propagated metadata values. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * fix(tracing): keep the exact digits of large integer metadata values Co-authored-by: Hassieb Pakzad * fix(tracing): match JS for nested big ints and non-finite metadata Integers keep their exact digits as JSON numbers at any depth, and propagated metadata values containing NaN or Infinity are dropped with a warning instead of being stored, matching the JS SDK. Co-authored-by: Hassieb Pakzad * feat(client): keep host and LANGFUSE_HOST working without a warning Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/propagation.py | 61 ++++++++++++++-- tests/unit/test_propagate_attributes.py | 94 +++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index ecf961e4f..20aefe7f1 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,6 +5,7 @@ propagate to all child spans within the context. """ +import math import re from typing import ( Any, @@ -40,6 +41,7 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT +from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger from langfuse.model import PromptClient @@ -281,8 +283,11 @@ def propagate_attributes( - **Validation**: Attribute values (user_id, session_id, version, tags, trace_name) must be strings ≤200 characters. Environment must also match Langfuse's environment format: lowercase alphanumeric with optional - hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Metadata - values are coerced to strings before the 200 character limit is applied. + hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Non-string + metadata values are serialized like JavaScript's `JSON.stringify` + (compact separators, non-ASCII kept as is, None becomes "null", + integers keep their exact digits) before the 200 character limit is + applied. Values containing NaN or Infinity are dropped. Invalid values will be dropped with a warning logged. - **OpenTelemetry**: This uses OpenTelemetry context propagation under the hood, making it compatible with other OTel-instrumented libraries. @@ -393,10 +398,21 @@ def _propagate_attributes( validated_metadata: Dict[str, str] = {} for key, value in metadata_value.items(): - coerced_value = value if isinstance(value, str) else str(value) + serialized_value = _serialize_propagated_metadata_value(value) + + if serialized_value is None: + langfuse_logger.warning( + "Propagated attribute '%s.%s' contains NaN or Infinity, which " + "is not valid JSON. Dropping value.", + metadata_key, + key, + ) + continue - if _validate_string_value(value=coerced_value, key=f"{metadata_key}.{key}"): - validated_metadata[key] = coerced_value + if _validate_string_value( + value=serialized_value, key=f"{metadata_key}.{key}" + ): + validated_metadata[key] = serialized_value if validated_metadata: context = _set_propagated_attribute( @@ -641,6 +657,41 @@ def _validate_propagated_value( return value +class _PropagatedMetadataSerializer(EventSerializer): + """EventSerializer variant that matches the JS SDK for propagated metadata. + + Integers keep their exact digits as JSON numbers at any depth, and values + containing NaN or Infinity are flagged so the caller can drop them. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.found_non_finite_number = False + + def default(self, obj: Any) -> Any: + if isinstance(obj, int) and not isinstance(obj, bool): + return obj + + if isinstance(obj, float) and not math.isfinite(obj): + self.found_non_finite_number = True + return None + + return super().default(obj) + + +def _serialize_propagated_metadata_value(value: Any) -> Optional[str]: + """Serialize like JSON.stringify in the JS SDK; None means drop the value.""" + if isinstance(value, str): + return value + + serializer = _PropagatedMetadataSerializer( + separators=(",", ":"), ensure_ascii=False + ) + serialized = serializer.encode(value) + + return None if serializer.found_non_finite_number else serialized + + def _validate_string_value(*, value: str, key: str) -> bool: if not isinstance(value, str): langfuse_logger.warning( # type: ignore diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index cdf4f9351..2330269f0 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -461,10 +461,10 @@ def test_non_string_user_id_dropped(self, langfuse_client, memory_exporter): child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID ) - def test_non_string_metadata_values_coerced( + def test_non_string_metadata_values_json_serialized( self, langfuse_client, memory_exporter, caplog ): - """Verify non-string metadata values are coerced instead of dropped.""" + """Verify non-string metadata values are JSON-serialized instead of dropped.""" caplog.set_level("WARNING", logger="langfuse") metadata = { @@ -472,6 +472,23 @@ def test_non_string_metadata_values_coerced( "langgraph_triggers": ["branch:agent"], "langgraph_path": ("root", "agent"), "max_search_results": 5, + "is_cached": True, + "ratio": 0.5, + "config": {"model": "gpt-4o", "nested": {"b": [1, None], "a": "ü"}}, + "label": ["Läufe", "🚀"], + "empty": None, + } + # Byte-identical to JSON.stringify in the JS SDK for the same values. + expected = { + "langgraph_step": "1", + "langgraph_triggers": '["branch:agent"]', + "langgraph_path": '["root","agent"]', + "max_search_results": "5", + "is_cached": "true", + "ratio": "0.5", + "config": '{"model":"gpt-4o","nested":{"b":[1,null],"a":"ü"}}', + "label": '["Läufe","🚀"]', + "empty": "null", } with langfuse_client.start_as_current_observation(name="parent-span"): @@ -481,15 +498,84 @@ def test_non_string_metadata_values_coerced( child_span = self.get_span_by_name(memory_exporter, "child-span") - for key, value in metadata.items(): + for key, value in expected.items(): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}", - str(value), + value, ) assert "value is not a string. Dropping value." not in caplog.text + def test_large_integer_metadata_keeps_its_digits( + self, langfuse_client, memory_exporter + ): + """Verify integers beyond JS's safe range are sent as plain digits.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"snowflake_id": 9007199254740993}): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.snowflake_id", + "9007199254740993", + ) + + def test_nested_large_integer_metadata_keeps_its_digits( + self, langfuse_client, memory_exporter + ): + """Verify nested integers beyond JS's safe range stay unquoted digits.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={ + "ids": [9007199254740993, 1], + "ref": {"snowflake_id": 9007199254740993}, + } + ): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ids", + "[9007199254740993,1]", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ref", + '{"snowflake_id":9007199254740993}', + ) + + def test_non_finite_number_metadata_is_dropped( + self, langfuse_client, memory_exporter, caplog + ): + """Verify values containing NaN or Infinity are dropped, like in the JS SDK.""" + caplog.set_level("WARNING", logger="langfuse") + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={ + "kept": 1.5, + "nan": float("nan"), + "inf": float("-inf"), + "nested": {"scores": [1.0, float("inf")]}, + } + ): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.kept", "1.5" + ) + for key in ("nan", "inf", "nested"): + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + ) + assert "metadata.nan" in caplog.text + def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): """Verify mixed valid/invalid metadata - valid entries kept, invalid dropped.""" with langfuse_client.start_as_current_observation(name="parent-span"): From 3059c39a7de2af64c7a968a00e243e6919e44fb9 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:50:08 +0400 Subject: [PATCH 06/31] feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run (#1933) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * feat(client)!: remove blocked_instrumentation_scopes blocked_instrumentation_scopes was deprecated in favor of should_export_span. Remove it from Langfuse, the resource manager and the span processor, and drop the leftover is_active entry from the create_prompt docstring. LANGFUSE_HOST and host keep working, but now log a deprecation warning when they decide the base URL. Co-authored-by: Hassieb Pakzad * feat(tracing)!: JSON-serialize propagated metadata values propagate_attributes(metadata=...) coerced non-string values with str(), so a boolean became "True" and a list became its Python repr. Serialize them as JSON instead, like observation metadata, and drop None values instead of sending "None". Co-authored-by: Hassieb Pakzad * feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run These helpers call the dataset-run endpoints that Langfuse v4 no longer serves, so they always fail with a 404. Read experiment runs through langfuse.api.experiments.list() and list_items() instead. Co-authored-by: Hassieb Pakzad * fix(tracing): serialize propagated metadata like JSON.stringify Use compact separators, keep non-ASCII characters, and send None as "null", so the Python and JS SDKs emit byte-identical propagated metadata values. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * fix(tracing): keep the exact digits of large integer metadata values Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * fix(tracing): match JS for nested big ints and non-finite metadata Integers keep their exact digits as JSON numbers at any depth, and propagated metadata values containing NaN or Infinity are dropped with a warning instead of being stored, matching the JS SDK. Co-authored-by: Hassieb Pakzad * feat(client): keep host and LANGFUSE_HOST working without a warning Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/client.py | 84 -------------------------------------- 1 file changed, 84 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 10b9b52e4..adf210a3a 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -102,14 +102,11 @@ Dataset, DatasetItem, DatasetItemMediaReferenceField, - DatasetRunWithItems, DatasetStatus, - DeleteDatasetRunResponse, Error, LangfuseAPI, MapValue, NotFoundError, - PaginatedDatasetRuns, Prompt_Chat, Prompt_Text, ScoreBody, @@ -2504,87 +2501,6 @@ def get_dataset( handle_fern_exception(e) raise e - def get_dataset_run( - self, *, dataset_name: str, run_name: str - ) -> DatasetRunWithItems: - """Fetch a dataset run by dataset name and run name. - - Args: - dataset_name (str): The name of the dataset. - run_name (str): The name of the run. - - Returns: - DatasetRunWithItems: The dataset run with its items. - """ - try: - return cast( - DatasetRunWithItems, - self.api.datasets.get_run( - dataset_name=self._url_encode(dataset_name), - run_name=self._url_encode(run_name), - request_options=None, - ), - ) - except Error as e: - handle_fern_exception(e) - raise e - - def get_dataset_runs( - self, - *, - dataset_name: str, - page: Optional[int] = None, - limit: Optional[int] = None, - ) -> PaginatedDatasetRuns: - """Fetch all runs for a dataset. - - Args: - dataset_name (str): The name of the dataset. - page (Optional[int]): Page number, starts at 1. - limit (Optional[int]): Limit of items per page. - - Returns: - PaginatedDatasetRuns: Paginated list of dataset runs. - """ - try: - return cast( - PaginatedDatasetRuns, - self.api.datasets.get_runs( - dataset_name=self._url_encode(dataset_name), - page=page, - limit=limit, - request_options=None, - ), - ) - except Error as e: - handle_fern_exception(e) - raise e - - def delete_dataset_run( - self, *, dataset_name: str, run_name: str - ) -> DeleteDatasetRunResponse: - """Delete a dataset run and all its run items. This action is irreversible. - - Args: - dataset_name (str): The name of the dataset. - run_name (str): The name of the run. - - Returns: - DeleteDatasetRunResponse: Confirmation of deletion. - """ - try: - return cast( - DeleteDatasetRunResponse, - self.api.datasets.delete_run( - dataset_name=self._url_encode(dataset_name), - run_name=self._url_encode(run_name), - request_options=None, - ), - ) - except Error as e: - handle_fern_exception(e) - raise e - def run_experiment( self, *, From 661692b9af873be1dd9b62471b1e3691561697c5 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:25:24 +0400 Subject: [PATCH 07/31] feat(experiments)!: record experiment runs via OTEL only (#1934) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * feat(client)!: remove blocked_instrumentation_scopes blocked_instrumentation_scopes was deprecated in favor of should_export_span. Remove it from Langfuse, the resource manager and the span processor, and drop the leftover is_active entry from the create_prompt docstring. LANGFUSE_HOST and host keep working, but now log a deprecation warning when they decide the base URL. Co-authored-by: Hassieb Pakzad * feat(tracing)!: JSON-serialize propagated metadata values propagate_attributes(metadata=...) coerced non-string values with str(), so a boolean became "True" and a list became its Python repr. Serialize them as JSON instead, like observation metadata, and drop None values instead of sending "None". Co-authored-by: Hassieb Pakzad * feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run These helpers call the dataset-run endpoints that Langfuse v4 no longer serves, so they always fail with a 404. Read experiment runs through langfuse.api.experiments.list() and list_items() instead. Co-authored-by: Hassieb Pakzad * feat(experiments)!: record experiment runs via OTEL only Generate the experiment id client-side instead of creating dataset run items via POST /dataset-run-items. Dataset-backed runs derive a stable id from project, dataset and run name (matching the server's derivation), so re-runs with the same run name keep grouping into one experiment. Local runs keep a random id. - set langfuse.experiment.item.version from the pinned dataset version - always persist run evaluations as scores on the experiment, incl. local data - return experiment_id / experiment_url (v4 results page) on results; dataset_run_id / dataset_run_url remain as deprecated aliases BREAKING CHANGE: run_experiment no longer creates Postgres dataset runs; dataset_run_id / dataset_run_url are deprecated aliases that are now also set for local-data experiments, and the URL points to the experiments page. Co-authored-by: Hassieb Pakzad * test(experiments): read experiment e2e results via v4 APIs Replace dataset run, dataset run item and legacy trace reads with the experiments, observations and v3 scores APIs, and cover stable experiment ids, run-level scores for local data and the recorded item version. Co-authored-by: Hassieb Pakzad * test(experiments): pin the cross-SDK experiment id vectors Co-authored-by: Hassieb Pakzad * fix(tracing): serialize propagated metadata like JSON.stringify Use compact separators, keep non-ASCII characters, and send None as "null", so the Python and JS SDKs emit byte-identical propagated metadata values. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * fix(tracing): keep the exact digits of large integer metadata values Co-authored-by: Hassieb Pakzad * fix(experiments): skip orphaned run scores and offline project lookups Run-level scores are only sent when at least one item trace is sampled, because a fully sampled-out experiment never reaches the server. Clients with tracing disabled no longer resolve the project id. Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * fix(tracing): match JS for nested big ints and non-finite metadata Integers keep their exact digits as JSON numbers at any depth, and propagated metadata values containing NaN or Infinity are dropped with a warning instead of being stored, matching the JS SDK. Co-authored-by: Hassieb Pakzad * feat(client): keep host and LANGFUSE_HOST working without a warning Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/attributes.py | 1 + langfuse/_client/client.py | 224 +++++++----- langfuse/_client/datasets.py | 41 +-- langfuse/experiment.py | 77 +++-- tests/e2e/test_experiments.py | 435 ++++++++++++++---------- tests/unit/test_experiment.py | 269 ++++++++++++++- tests/unit/test_propagate_attributes.py | 38 +-- 7 files changed, 766 insertions(+), 319 deletions(-) diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index 4660b50f0..43a85c2fd 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -68,6 +68,7 @@ class LangfuseOtelSpanAttributes: EXPERIMENT_METADATA = "langfuse.experiment.metadata" EXPERIMENT_DATASET_ID = "langfuse.experiment.dataset.id" EXPERIMENT_ITEM_ID = "langfuse.experiment.item.id" + EXPERIMENT_ITEM_VERSION = "langfuse.experiment.item.version" EXPERIMENT_ITEM_EXPECTED_OUTPUT = "langfuse.experiment.item.expected_output" EXPERIMENT_ITEM_METADATA = "langfuse.experiment.item.metadata" EXPERIMENT_ITEM_ROOT_OBSERVATION_ID = "langfuse.experiment.item.root_observation_id" diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index adf210a3a..dbcb8c9ee 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -2533,8 +2533,9 @@ def run_experiment( Args: name: Human-readable name for the experiment. Used for identification in the Langfuse UI. - run_name: Optional exact name for the experiment run. If provided, this will be - used as the exact dataset run name if the `data` contains Langfuse dataset items. + run_name: Optional exact name for the experiment run. If the `data` contains + Langfuse dataset items, runs with the same `run_name` on the same dataset + share one experiment ID and appear as a single experiment in Langfuse. If not provided, this will default to the experiment name appended with an ISO timestamp. description: Optional description explaining the experiment's purpose, methodology, or expected outcomes. @@ -2558,17 +2559,22 @@ def run_experiment( Controls the number of items processed simultaneously. Adjust based on API rate limits and system resources. metadata: Optional metadata dictionary to attach to all experiment traces. - This metadata will be included in every trace created during the experiment. - If `data` are Langfuse dataset items, the metadata will be attached to the dataset run, too. + This metadata will be included in every trace created during the experiment + and is shown as the experiment's metadata in Langfuse. Returns: ExperimentResult containing: - - run_name: The experiment run name. This is equal to the dataset run name if experiment was on Langfuse dataset. + - run_name: The experiment run name, shown as the experiment name in Langfuse. - item_results: List of results for each processed item with outputs and evaluations - - run_evaluations: List of aggregate evaluation results for the entire run - - experiment_id: Stable identifier for the experiment run across all items - - dataset_run_id: ID of the dataset run (if using Langfuse datasets) - - dataset_run_url: Direct URL to view results in Langfuse UI (if applicable) + - run_evaluations: List of aggregate evaluation results for the entire run. + They are stored as scores on the experiment run, also for local data. + - experiment_id: Identifier for the experiment run across all items. For + Langfuse dataset items it is derived from the project ID, dataset ID and + run name; for local data it is random. + - experiment_url: Direct URL to view results in Langfuse UI (None if the + project ID could not be resolved) + - dataset_run_id / dataset_run_url: Deprecated aliases of experiment_id / + experiment_url Raises: ValueError: If required parameters are missing or invalid @@ -2666,14 +2672,16 @@ def average_accuracy(*, item_results, **kwargs): ) # Results automatically linked to dataset in Langfuse UI - print(f"View results: {result['dataset_run_url']}") + print(f"View results: {result.experiment_url}") ``` Note: - Task and evaluator functions can be either synchronous or asynchronous - Individual item failures are logged but don't stop the experiment - All executions are automatically traced and visible in Langfuse UI - - When using Langfuse datasets, results are automatically linked for easy comparison + - Experiment runs are recorded only through the exported traces; no + separate dataset run is created via the API + - When using Langfuse datasets, results are automatically linked to the dataset for easy comparison - This method works in both sync and async contexts (Jupyter notebooks, web apps, etc.) - Async execution is handled automatically with smart event loop detection """ @@ -2717,7 +2725,34 @@ async def _run_experiment_async( "Starting experiment '%s' run '%s' with %s items", name, run_name, len(data) ) - shared_fallback_experiment_id = self._create_observation_id() + project_id: Optional[str] = None + if self._tracing_enabled: + try: + project_id = await asyncio.to_thread(self._get_project_id) + except Exception as e: + langfuse_logger.warning( + "Failed to resolve project id for experiment: %s", e + ) + + # One experiment id per run: mixed-dataset data uses the first dataset item's dataset. + experiment_dataset_id = next( + ( + getattr(item, "dataset_id", None) + for item in data + if not isinstance(item, dict) and getattr(item, "dataset_id", None) + ), + None, + ) + experiment_id = self._create_experiment_id( + project_id=project_id, + dataset_id=experiment_dataset_id, + run_name=run_name, + ) + experiment_item_version = ( + self._format_experiment_item_version(dataset_version) + if dataset_version is not None + else None + ) # Set up concurrency control semaphore = asyncio.Semaphore(max_concurrency) @@ -2730,12 +2765,12 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: task, evaluators, composite_evaluator, - shared_fallback_experiment_id, + experiment_id, name, run_name, description, metadata, - dataset_version, + experiment_item_version, ) # Run all items concurrently @@ -2761,47 +2796,40 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: except Exception as e: langfuse_logger.error("Run evaluator failed: %s", e) - # Generate dataset run URL if applicable - dataset_run_id = next( - ( - result.dataset_run_id - for result in valid_results - if result.dataset_run_id - ), - None, + experiment_url = ( + f"{self._base_url}/project/{project_id}/experiments/results?baseline={experiment_id}" + if project_id + else None ) - dataset_run_url = None - if dataset_run_id and data: - try: - # Check if the first item has dataset_id (for DatasetItem objects) - first_item = data[0] - dataset_id = None - if hasattr(first_item, "dataset_id"): - dataset_id = getattr(first_item, "dataset_id", None) - - if dataset_id: - project_id = self._get_project_id() - - if project_id: - dataset_run_url = f"{self._base_url}/project/{project_id}/datasets/{dataset_id}/runs/{dataset_run_id}" - - except Exception: - pass # URL generation is optional + # Without an exported item span the experiment does not exist on the server, + # so a run score would be orphaned. + stored_run_evaluations = ( + run_evaluations + if any( + result.trace_id is not None and self._is_trace_sampled(result.trace_id) + for result in valid_results + ) + else [] + ) + if run_evaluations and not stored_run_evaluations: + langfuse_logger.debug( + "Skipping run-level scores for experiment %s: all items were sampled out.", + experiment_id, + ) - # Store run-level evaluations as scores - for evaluation in run_evaluations: + # Run-level scores attach to the experiment via dataset_run_id == experiment_id. + for evaluation in stored_run_evaluations: try: - if dataset_run_id: - self.create_score( - dataset_run_id=dataset_run_id, - name=evaluation.name or "", - value=evaluation.value, # type: ignore - comment=evaluation.comment, - metadata=evaluation.metadata, - data_type=evaluation.data_type, # type: ignore - config_id=evaluation.config_id, - ) + self.create_score( + dataset_run_id=experiment_id, + name=evaluation.name or "", + value=evaluation.value, # type: ignore + comment=evaluation.comment, + metadata=evaluation.metadata, + data_type=evaluation.data_type, # type: ignore + config_id=evaluation.config_id, + ) except Exception as e: langfuse_logger.error("Failed to store run evaluation: %s", e) @@ -2815,23 +2843,75 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: description=description, item_results=valid_results, run_evaluations=run_evaluations, - experiment_id=dataset_run_id or shared_fallback_experiment_id, - dataset_run_id=dataset_run_id, - dataset_run_url=dataset_run_url, + experiment_id=experiment_id, + experiment_url=experiment_url, ) + def _create_experiment_id( + self, + *, + project_id: Optional[str], + dataset_id: Optional[str], + run_name: str, + ) -> str: + if dataset_id is None: + return self._create_observation_id() + + if project_id is None: + langfuse_logger.warning( + "Could not resolve the project id; using a random experiment id for run '%s'. " + "Re-runs with the same run name will not be grouped into the same experiment.", + run_name, + ) + return self._create_observation_id() + + import json + + # Must match the Langfuse server's stable experiment id derivation byte for + # byte (JS JSON.stringify of the same array, SHA-256, first 16 hex chars). + payload = json.dumps( + ["langfuse-experiment-v1", project_id, dataset_id, run_name], + separators=(",", ":"), + ensure_ascii=False, + ) + + return sha256(payload.encode("utf-8")).hexdigest()[:16] + + def _is_trace_sampled(self, trace_id: str) -> bool: + from opentelemetry.sdk.trace.sampling import Decision + + tracer_provider = self._resources.tracer_provider if self._resources else None + sampler = getattr(tracer_provider, "sampler", None) + if sampler is None: + return True + + try: + decision = sampler.should_sample( + parent_context=None, trace_id=int(trace_id, 16), name="experiment" + ).decision + except Exception: + return True + + return bool(decision == Decision.RECORD_AND_SAMPLE) + + @staticmethod + def _format_experiment_item_version(dataset_version: datetime) -> str: + from langfuse.api.core.datetime_utils import serialize_datetime + + return serialize_datetime(dataset_version) + async def _process_experiment_item( self, item: ExperimentItem, task: Callable, evaluators: List[Callable], composite_evaluator: Optional[CompositeEvaluatorFunction], - fallback_experiment_id: str, + experiment_id: str, experiment_name: str, experiment_run_name: str, experiment_description: Optional[str], experiment_metadata: Optional[Dict[str, Any]] = None, - dataset_version: Optional[datetime] = None, + experiment_item_version: Optional[str] = None, ) -> ExperimentItemResult: with self.start_as_current_observation(name="experiment-item-run") as span: try: @@ -2866,7 +2946,6 @@ async def _process_experiment_item( trace_id = span.trace_id dataset_id = None dataset_item_id = None - dataset_run_id = None if ( not isinstance(item, dict) @@ -2891,6 +2970,9 @@ async def _process_experiment_item( LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT: _serialize( expected_output ), + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_VERSION: ( + experiment_item_version if dataset_id else None + ), }.items() if v is not None } @@ -2904,32 +2986,6 @@ async def _process_experiment_item( ) as task_span: task_span._otel_span.set_attributes(experiment_span_attributes) - # Link dataset runs to the canonical task observation so their - # latency excludes the subsequent evaluator subtree. - if hasattr(item, "id") and hasattr(item, "dataset_id"): - try: - # Use sync API to avoid event loop issues when - # run_async_safely creates multiple event loops across - # different threads. - dataset_run_item = await asyncio.to_thread( - self.api.dataset_run_items.create, - run_name=experiment_run_name, - run_description=experiment_description, - metadata=experiment_metadata, - dataset_item_id=item.id, # type: ignore - trace_id=trace_id, - observation_id=task_span.id, - dataset_version=dataset_version, - ) - - dataset_run_id = dataset_run_item.dataset_run_id - - except Exception as e: - langfuse_logger.error( - "Failed to create dataset run item: %s", e - ) - - experiment_id = dataset_run_id or fallback_experiment_id propagated_experiment_attributes = PropagatedExperimentAttributes( experiment_id=experiment_id, experiment_name=experiment_run_name, @@ -3131,7 +3187,7 @@ async def _process_experiment_item( output=output, evaluations=evaluations, trace_id=trace_id, - dataset_run_id=dataset_run_id, + experiment_id=experiment_id, ) def _create_experiment_run_name( diff --git a/langfuse/_client/datasets.py b/langfuse/_client/datasets.py index e28ddbebf..1070c76d3 100644 --- a/langfuse/_client/datasets.py +++ b/langfuse/_client/datasets.py @@ -94,11 +94,11 @@ def run_experiment( """Run an experiment on this Langfuse dataset with automatic tracking. This is a convenience method that runs an experiment using all items in this - dataset. It automatically creates a dataset run in Langfuse for tracking and - comparison purposes, linking all experiment results to the dataset. + dataset. All results are recorded as one experiment in Langfuse that is linked + to the dataset for tracking and comparison purposes. Key benefits of using dataset.run_experiment(): - - Automatic dataset run creation and linking in Langfuse UI + - Automatic experiment tracking and linking to the dataset in Langfuse UI - Built-in experiment tracking and versioning - Easy comparison between different experiment runs - Direct access to dataset items with their metadata and expected outputs @@ -106,10 +106,11 @@ def run_experiment( Args: name: Human-readable name for the experiment run. This will be used as - the dataset run name in Langfuse for tracking and identification. - run_name: Optional exact name for the dataset run. If provided, this will be - used as the exact dataset run name in Langfuse. If not provided, this will - default to the experiment name appended with an ISO timestamp. + the base of the experiment run name in Langfuse for tracking and identification. + run_name: Optional exact name for the experiment run. Runs with the same + `run_name` on this dataset share one experiment ID and appear as a single + experiment in Langfuse. If not provided, this will default to the + experiment name appended with an ISO timestamp. description: Optional description of the experiment's purpose, methodology, or what you're testing. Appears in the Langfuse UI for context. task: Function that processes each dataset item and returns output. @@ -131,12 +132,14 @@ def run_experiment( Returns: ExperimentResult object containing: - name: The experiment name. - - run_name: The experiment run name (equivalent to the dataset run name). + - run_name: The experiment run name, shown as the experiment name in Langfuse. - description: Optional experiment description. - item_results: Results for each dataset item with outputs and evaluations. - run_evaluations: Aggregate evaluation results for the entire run. - - dataset_run_id: ID of the created dataset run in Langfuse. - - dataset_run_url: Direct URL to view the experiment results in Langfuse UI. + - experiment_id: ID of the experiment run in Langfuse. + - experiment_url: Direct URL to view the experiment results in Langfuse UI. + - dataset_run_id / dataset_run_url: Deprecated aliases of experiment_id / + experiment_url. The result object provides a format() method for human-readable output: ```python @@ -176,8 +179,8 @@ def accuracy_evaluator(*, input, output, expected_output=None, **kwargs): evaluators=[accuracy_evaluator] ) - print(f"Evaluated {len(result['item_results'])} questions") - print(f"View detailed results: {result['dataset_run_url']}") + print(f"Evaluated {len(result.item_results)} questions") + print(f"View detailed results: {result.experiment_url}") ``` Advanced experiment with multiple evaluators and run-level analysis: @@ -244,11 +247,11 @@ def content_diversity(*, item_results, **kwargs): ) # Results are automatically linked to dataset in Langfuse - print(f"Experiment completed! View in Langfuse: {result['dataset_run_url']}") + print(f"Experiment completed! View in Langfuse: {result.experiment_url}") # Access individual results - for i, item_result in enumerate(result["item_results"]): - print(f"Item {i+1}: {item_result['evaluations']}") + for i, item_result in enumerate(result.item_results): + print(f"Item {i+1}: {item_result.evaluations}") ``` Comparing different model versions: @@ -274,15 +277,15 @@ def content_diversity(*, item_results, **kwargs): # Both experiments are now visible in Langfuse for easy comparison print("Compare results in Langfuse:") - print(f"GPT-4: {result_gpt4.dataset_run_url}") - print(f"Custom: {result_custom.dataset_run_url}") + print(f"GPT-4: {result_gpt4.experiment_url}") + print(f"Custom: {result_custom.experiment_url}") ``` Note: - - All experiment results are automatically tracked in Langfuse as dataset runs + - All experiment results are automatically tracked in Langfuse as experiments on this dataset - Dataset items provide .input, .expected_output, and .metadata attributes - Results can be easily compared across different experiment runs in the UI - - The dataset_run_url provides direct access to detailed results and analysis + - The experiment_url provides direct access to detailed results and analysis - Failed items are handled gracefully and logged without stopping the experiment - This method works in both sync and async contexts (Jupyter notebooks, web apps, etc.) - Async execution is handled automatically with smart event loop detection diff --git a/langfuse/experiment.py b/langfuse/experiment.py index aa5481829..ee29d8611 100644 --- a/langfuse/experiment.py +++ b/langfuse/experiment.py @@ -6,6 +6,7 @@ """ import asyncio +import warnings from datetime import datetime from typing import ( TYPE_CHECKING, @@ -216,6 +217,14 @@ def __init__( self.config_id = config_id +def _warn_deprecated_alias(old: str, new: str) -> None: + warnings.warn( + f"{old} is deprecated and will be removed in a future major version. Use {new} instead.", + DeprecationWarning, + stacklevel=3, + ) + + class ExperimentItemResult: """Result structure for individual experiment items. @@ -233,8 +242,8 @@ class ExperimentItemResult: contains a name, value, optional comment, and optional metadata. trace_id: Optional Langfuse trace ID for this item's execution. Used to link the experiment result with the detailed trace in Langfuse UI. - dataset_run_id: Optional dataset run ID if this item was part of a - Langfuse dataset. None for local experiments. + experiment_id: ID of the experiment run this item belongs to. + dataset_run_id: Deprecated alias of `experiment_id`. Examples: Accessing item result data: @@ -275,7 +284,8 @@ def __init__( output: Any, evaluations: List[Evaluation], trace_id: Optional[str], - dataset_run_id: Optional[str], + experiment_id: Optional[str] = None, + dataset_run_id: Optional[str] = None, ): """Initialize an ExperimentItemResult with the provided data. @@ -284,7 +294,9 @@ def __init__( output: The actual output produced by the task function for this item. evaluations: List of evaluation results for this item. trace_id: Optional Langfuse trace ID for this item's execution. - dataset_run_id: Optional dataset run ID if this item was part of a Langfuse dataset. + experiment_id: ID of the experiment run this item belongs to. + dataset_run_id: Deprecated alias of `experiment_id`. Used only when + `experiment_id` is not provided. Note: All arguments must be provided as keywords. Positional arguments will raise a TypeError. @@ -293,7 +305,13 @@ def __init__( self.output = output self.evaluations = evaluations self.trace_id = trace_id - self.dataset_run_id = dataset_run_id + self.experiment_id = experiment_id or dataset_run_id + + @property + def dataset_run_id(self) -> Optional[str]: + """Deprecated alias of `experiment_id`.""" + _warn_deprecated_alias("ExperimentItemResult.dataset_run_id", "experiment_id") + return self.experiment_id class ExperimentResult: @@ -312,10 +330,13 @@ class ExperimentResult: run_evaluations: List of aggregate evaluation results computed across all items, such as average scores, statistical summaries, or cross-item analyses. experiment_id: ID of the experiment run propagated across all items. For - Langfuse datasets, this matches the dataset run ID. For local experiments, - this is a stable SDK-generated identifier for the run. - dataset_run_id: Optional ID of the dataset run in Langfuse (when using Langfuse datasets). - dataset_run_url: Optional direct URL to view the experiment results in Langfuse UI. + Langfuse datasets, it is derived from the project, dataset and run name, + so re-running with the same `run_name` adds to the same experiment. For + local experiments, it is a random SDK-generated identifier for the run. + experiment_url: Optional direct URL to view the experiment results in the + Langfuse UI. None if the project ID could not be resolved. + dataset_run_id: Deprecated alias of `experiment_id`. + dataset_run_url: Deprecated alias of `experiment_url`. Examples: Basic usage with local dataset: @@ -347,8 +368,8 @@ class ExperimentResult: ) # View in Langfuse UI - if result.dataset_run_url: - print(f"View detailed results: {result.dataset_run_url}") + if result.experiment_url: + print(f"View detailed results: {result.experiment_url}") ``` Formatted output: @@ -373,6 +394,7 @@ def __init__( item_results: List[ExperimentItemResult], run_evaluations: List[Evaluation], experiment_id: str, + experiment_url: Optional[str] = None, dataset_run_id: Optional[str] = None, dataset_run_url: Optional[str] = None, ): @@ -385,8 +407,11 @@ def __init__( item_results: List of results from processing individual dataset items. run_evaluations: List of aggregate evaluation results for the entire run. experiment_id: ID of the experiment run. - dataset_run_id: Optional ID of the dataset run (for Langfuse datasets). - dataset_run_url: Optional URL to view results in Langfuse UI. + experiment_url: Optional URL to view results in Langfuse UI. + dataset_run_id: Deprecated and ignored; `dataset_run_id` always + returns `experiment_id`. + dataset_run_url: Deprecated alias of `experiment_url`. Used only when + `experiment_url` is not provided. """ self.name = name self.run_name = run_name @@ -394,8 +419,19 @@ def __init__( self.item_results = item_results self.run_evaluations = run_evaluations self.experiment_id = experiment_id - self.dataset_run_id = dataset_run_id - self.dataset_run_url = dataset_run_url + self.experiment_url = experiment_url or dataset_run_url + + @property + def dataset_run_id(self) -> str: + """Deprecated alias of `experiment_id`.""" + _warn_deprecated_alias("ExperimentResult.dataset_run_id", "experiment_id") + return self.experiment_id + + @property + def dataset_run_url(self) -> Optional[str]: + """Deprecated alias of `experiment_url`.""" + _warn_deprecated_alias("ExperimentResult.dataset_run_url", "experiment_url") + return self.experiment_url def format(self, *, include_item_results: bool = False) -> str: r"""Format the experiment result for human-readable display. @@ -426,7 +462,7 @@ def format(self, *, include_item_results: bool = False) -> str: - List of all evaluation metrics that were applied - Average scores across all items for each numeric metric - Run-level evaluation results with comments - - Dataset run URL for viewing in Langfuse UI (if applicable) + - Experiment URL for viewing in Langfuse UI (if available) - Individual item details including inputs, outputs, and scores (if requested) Examples: @@ -588,9 +624,8 @@ def format(self, *, include_item_results: bool = False) -> str: output += f"\n 💭 {run_eval.comment}" output += "\n" - # Add dataset run URL if available - if self.dataset_run_url: - output += f"\n🔗 Dataset Run:\n {self.dataset_run_url}" + if self.experiment_url: + output += f"\n🔗 Experiment:\n {self.experiment_url}" return output @@ -845,7 +880,7 @@ def __call__( - output: The task function's output for this item - evaluations: List of item-level evaluation results - trace_id: Langfuse trace ID for this execution - - dataset_run_id: Dataset run ID (if using Langfuse datasets) + - experiment_id: ID of the experiment run Note: This list only includes items that were successfully processed. Failed items are excluded but logged separately. @@ -1106,7 +1141,7 @@ def __init__( dataset_version: Optional pinned dataset version. Injected by the action when ``dataset_version`` is configured. metadata: Default metadata attached to every experiment trace and - the dataset run. The action injects GitHub-sourced tags (SHA, + the experiment run. The action injects GitHub-sourced tags (SHA, PR link, workflow run link, branch, GH user, etc.). Merged with any ``metadata`` passed to :meth:`run_experiment`, with user-supplied keys winning on collision. diff --git a/tests/e2e/test_experiments.py b/tests/e2e/test_experiments.py index 80881f144..ceddba7be 100644 --- a/tests/e2e/test_experiments.py +++ b/tests/e2e/test_experiments.py @@ -1,20 +1,121 @@ """Comprehensive tests for Langfuse experiment functionality matching JS SDK.""" +import os import time -from typing import Any, Dict, List +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, List, TypeVar +from uuid import uuid4 import pytest from opentelemetry import trace as otel_trace_api from langfuse import get_client from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse.api import LangfuseAPI from langfuse.experiment import ( Evaluation, ExperimentData, ExperimentItem, ExperimentItemResult, ) -from tests.support.utils import create_uuid, get_api, wait_for_trace + +T = TypeVar("T") + +READ_TIMEOUT_SECONDS = float(os.environ.get("LANGFUSE_E2E_READ_TIMEOUT_SECONDS", "30")) +READ_INTERVAL_SECONDS = float( + os.environ.get("LANGFUSE_E2E_READ_INTERVAL_SECONDS", "0.5") +) +ITEM_FIELDS = "core,dataset,io,metadata,experimentMetadata,scores" + + +def create_uuid() -> str: + return str(uuid4()) + + +def get_api() -> LangfuseAPI: + return LangfuseAPI( + username=os.environ.get("LANGFUSE_PUBLIC_KEY"), + password=os.environ.get("LANGFUSE_SECRET_KEY"), + base_url=os.environ.get("LANGFUSE_BASE_URL"), + ) + + +def _read_window_start() -> datetime: + return datetime.now(timezone.utc) - timedelta(hours=1) + + +def poll(operation: Callable[[], T], is_ready: Callable[[T], bool]) -> T: + """Re-read until ready or timeout; returns the last result for the caller to assert.""" + deadline = time.monotonic() + READ_TIMEOUT_SECONDS + while True: + result = operation() + if is_ready(result) or time.monotonic() >= deadline: + return result + time.sleep(READ_INTERVAL_SECONDS) + + +def get_experiment_items( + experiment_id: str, *, expected_count: int, with_scores: bool = False +) -> list: + api = get_api() + return poll( + lambda: ( + api.experiments.list_items( + from_start_time=_read_window_start(), + experiment_id=experiment_id, + fields=ITEM_FIELDS, + limit=100, + ).data + ), + lambda items: ( + len(items) >= expected_count + and (not with_scores or all(item.scores for item in items)) + ), + ) + + +def get_experiment( + experiment_id: str, *, is_ready: Callable[[Any], bool] = lambda _: True +): + api = get_api() + experiments = poll( + lambda: ( + api.experiments.list( + from_start_time=_read_window_start(), + id=experiment_id, + fields="core,metadata,scores", + ).data + ), + lambda data: len(data) == 1 and is_ready(data[0]), + ) + assert len(experiments) == 1, f"Experiment {experiment_id} should exist" + return experiments[0] + + +def get_observations(trace_id: str, *, is_ready: Callable[[list], bool]) -> list: + api = get_api() + return poll( + lambda: ( + api.observations.get_many( + trace_id=trace_id, + fields="core,basic,io,metadata", + from_start_time=_read_window_start(), + ).data + ), + is_ready, + ) + + +def get_trace_scores(trace_id: str, *, expected_count: int) -> list: + api = get_api() + return poll( + lambda: api.scores_v3.get_many_v3(trace_id=trace_id, fields="subject").data, + lambda scores: len(scores) >= expected_count, + ) + + +def has_score(name: str) -> Callable[[Any], bool]: + return lambda experiment: any(s.name == name for s in experiment.scores or []) @pytest.fixture @@ -78,63 +179,53 @@ def test_run_experiment_on_local_dataset(sample_dataset): assert len(result.item_results) == 3 assert len(result.run_evaluations) == 1 assert result.run_evaluations[0].name == "average_length" - assert result.dataset_run_id is None # No dataset_run_id for local datasets + assert len(result.experiment_id) == 16 + assert result.experiment_url is not None + assert result.experiment_url.endswith( + f"/experiments/results?baseline={result.experiment_id}" + ) + with pytest.warns(DeprecationWarning): + assert result.dataset_run_id == result.experiment_id # Validate item results structure for item_result in result.item_results: assert hasattr(item_result, "output") assert hasattr(item_result, "evaluations") assert hasattr(item_result, "trace_id") - assert ( - item_result.dataset_run_id is None - ) # No dataset_run_id for local datasets + assert item_result.experiment_id == result.experiment_id assert len(item_result.evaluations) == 2 # Both evaluators should run - # Flush and wait for server processing langfuse_client.flush() - time.sleep(2) - - # Validate traces are correctly persisted with input/output/metadata - api = get_api() - expected_inputs = ["Germany", "France", "Spain"] - expected_outputs = ["Capital of Germany", "Capital of France", "Capital of Spain"] - - for i, item_result in enumerate(result.item_results): - trace_id = item_result.trace_id - assert trace_id is not None, f"Item {i} should have a trace_id" - - # Fetch trace from API - trace = api.trace.get(trace_id) - assert trace is not None, f"Trace {trace_id} should exist" - - # Validate trace name - assert trace.name == "experiment-item-run", ( - f"Trace {trace_id} should have correct name" - ) - - # Validate trace input - should contain the experiment item - assert trace.input is not None, f"Trace {trace_id} should have input" - expected_input = expected_inputs[i] - # The input should contain the item data in some form - assert expected_input in str(trace.input), ( - f"Trace {trace_id} input should contain '{expected_input}'" - ) - # Validate trace output - should be the task result - assert trace.output is not None, f"Trace {trace_id} should have output" - expected_output = expected_outputs[i] - assert trace.output == expected_output, ( - f"Trace {trace_id} output should be '{expected_output}', got '{trace.output}'" - ) + expected = { + "Germany": ("Capital of Germany", "Berlin"), + "France": ("Capital of France", "Paris"), + "Spain": ("Capital of Spain", "Madrid"), + } + items = get_experiment_items(result.experiment_id, expected_count=3) + assert len(items) == 3 + assert {item.trace_id for item in items} == { + r.trace_id for r in result.item_results + } - # Validate trace metadata contains experiment name - assert trace.metadata is not None, f"Trace {trace_id} should have metadata" - assert "experiment_name" in trace.metadata, ( - f"Trace {trace_id} metadata should contain experiment_name" - ) - assert trace.metadata["experiment_name"] == "Euro capitals", ( - f"Trace {trace_id} metadata should have correct experiment_name" - ) + for item in items: + assert item.experiment_name == result.run_name + assert item.experiment_dataset_id is None + assert item.input in expected + expected_output, expected_answer = expected[item.input] + assert item.output == expected_output + assert item.expected_output == expected_answer + assert item.metadata is not None + assert item.metadata["experiment_name"] == "Euro capitals" + + # Run-level evaluations are persisted for local data, too + experiment = get_experiment( + result.experiment_id, is_ready=has_score("average_length") + ) + assert experiment.name == result.run_name + assert experiment.description == "Country capital experiment" + assert experiment.item_count == 3 + assert [s.name for s in experiment.scores or []] == ["average_length"] def test_run_experiment_flattens_large_metadata_for_server_ingestion(): @@ -168,21 +259,25 @@ def task_with_external_child(*, item: ExperimentItem, **kwargs: Dict[str, Any]): trace_id = result.item_results[0].trace_id assert trace_id is not None - trace = wait_for_trace( + observations = get_observations( trace_id, - is_result_ready=lambda fetched_trace: any( - observation.name == external_span_name - for observation in fetched_trace.observations + is_ready=lambda observations: any( + observation.name == external_span_name for observation in observations ), ) - assert trace.metadata is not None + root_observation = next( + observation + for observation in observations + if observation.name == "experiment-item-run" + ) + assert root_observation.metadata is not None for metadata_key, metadata_value in experiment_metadata.items(): - assert trace.metadata[metadata_key] == metadata_value + assert root_observation.metadata[metadata_key] == metadata_value external_observation = next( observation - for observation in trace.observations + for observation in observations if observation.name == external_span_name ) external_metadata = external_observation.metadata or {} @@ -227,117 +322,109 @@ def test_run_experiment_on_langfuse_dataset(): run_evaluators=[run_evaluator_average_length], ) - # Should have dataset run ID for Langfuse datasets - assert result.dataset_run_id is not None + project_id = langfuse_client._get_project_id() + assert project_id is not None + assert result.experiment_id == langfuse_client._create_experiment_id( + project_id=project_id, dataset_id=dataset.id, run_name=result.run_name + ) + assert result.experiment_url == ( + f"{os.environ['LANGFUSE_BASE_URL']}/project/{project_id}" + f"/experiments/results?baseline={result.experiment_id}" + ) assert len(result.item_results) == 2 - assert all(item.dataset_run_id is not None for item in result.item_results) + assert all( + item.experiment_id == result.experiment_id for item in result.item_results + ) - # Flush and wait for server processing langfuse_client.flush() - time.sleep(3) - - # Verify dataset run exists via API - api = get_api() - dataset_run = api.datasets.get_run( - dataset_name=dataset_name, run_name=result.run_name - ) - # Validate traces are correctly persisted with input/output/metadata expected_data = {"Germany": "Capital of Germany", "France": "Capital of France"} - dataset_run_id = result.dataset_run_id - - # Create a mapping from dataset item ID to dataset item for validation dataset_item_map = {item.id: item for item in dataset.items} - for i, item_result in enumerate(result.item_results): - trace_id = item_result.trace_id - assert trace_id is not None, f"Item {i} should have a trace_id" + items = get_experiment_items( + result.experiment_id, expected_count=2, with_scores=True + ) + assert len(items) == 2, "Experiment should have 2 items" + assert {item.trace_id for item in items} == { + r.trace_id for r in result.item_results + } + assert {item.experiment_item_id for item in items} == set(dataset_item_map) - # Fetch trace from API - trace = api.trace.get(trace_id) - assert trace is not None, f"Trace {trace_id} should exist" + for item in items: + assert item.experiment_name == result.run_name + assert item.experiment_dataset_id == dataset.id + assert item.experiment_description == "Test on Langfuse dataset" + assert item.experiment_item_version is None - # Validate trace name - assert trace.name == "experiment-item-run", ( - f"Trace {trace_id} should have correct name" - ) + dataset_item = dataset_item_map[item.experiment_item_id] + assert item.input == dataset_item.input + assert item.output == expected_data[dataset_item.input] + assert item.expected_output == dataset_item.expected_output - # Validate trace input and output match expected pairs - assert trace.input is not None, f"Trace {trace_id} should have input" - trace_input_str = str(trace.input) + assert item.metadata is not None + assert item.metadata["experiment_name"] == experiment_name + assert item.metadata["dataset_id"] == dataset.id + assert item.metadata["dataset_item_id"] == item.experiment_item_id - # Find which expected input this trace corresponds to - matching_input = None - for expected_input in expected_data.keys(): - if expected_input in trace_input_str: - matching_input = expected_input - break + assert [s.name for s in item.scores or []] == ["factuality"] - assert matching_input is not None, ( - f"Trace {trace_id} input '{trace_input_str}' should contain one of {list(expected_data.keys())}" - ) + experiment = get_experiment( + result.experiment_id, is_ready=has_score("average_length") + ) + assert experiment.name == result.run_name + assert experiment.description == "Test on Langfuse dataset" + assert experiment.dataset_id == dataset.id + assert experiment.item_count == 2 + assert [s.name for s in experiment.scores or []] == ["average_length"] + assert experiment.scores[0].subject.kind == "experiment" + assert experiment.scores[0].subject.id == result.experiment_id - # Validate trace output matches the expected output for this input - assert trace.output is not None, f"Trace {trace_id} should have output" - expected_output = expected_data[matching_input] - assert trace.output == expected_output, ( - f"Trace {trace_id} output should be '{expected_output}', got '{trace.output}'" - ) - # Validate trace metadata contains experiment and dataset info - assert trace.metadata is not None, f"Trace {trace_id} should have metadata" - assert "experiment_name" in trace.metadata, ( - f"Trace {trace_id} metadata should contain experiment_name" - ) - assert trace.metadata["experiment_name"] == experiment_name, ( - f"Trace {trace_id} metadata should have correct experiment_name" - ) +def test_run_experiment_on_versioned_dataset_records_item_version(): + """Pinned dataset versions are recorded as the experiment item version.""" + langfuse_client = get_client() + dataset_name = "versioned-dataset-" + create_uuid() + langfuse_client.create_dataset(name=dataset_name) + langfuse_client.create_dataset_item( + dataset_name=dataset_name, input="Germany", expected_output="Berlin" + ) - # Validate dataset-specific metadata fields - assert "dataset_id" in trace.metadata, ( - f"Trace {trace_id} metadata should contain dataset_id" - ) - assert trace.metadata["dataset_id"] == dataset.id, ( - f"Trace {trace_id} metadata should have correct dataset_id" - ) + version = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(seconds=1) + time.sleep(1.5) + dataset = langfuse_client.get_dataset(dataset_name, version=version) + assert len(dataset.items) == 1 - assert "dataset_item_id" in trace.metadata, ( - f"Trace {trace_id} metadata should contain dataset_item_id" - ) - # Get the dataset item ID from metadata and validate it exists - dataset_item_id = trace.metadata["dataset_item_id"] - assert dataset_item_id in dataset_item_map, ( - f"Trace {trace_id} metadata dataset_item_id should correspond to a valid dataset item" - ) + result = dataset.run_experiment( + name="Versioned " + create_uuid()[:8], task=mock_task + ) + langfuse_client.flush() - # Validate the dataset item input matches the trace input - dataset_item = dataset_item_map[dataset_item_id] - assert dataset_item.input == matching_input, ( - f"Trace {trace_id} should correspond to dataset item with input '{matching_input}'" - ) + items = get_experiment_items(result.experiment_id, expected_count=1) + assert len(items) == 1 + assert items[0].experiment_item_version is not None + assert items[0].experiment_item_version.astimezone(timezone.utc) == version - assert dataset_run is not None, f"Dataset run {dataset_run_id} should exist" - assert dataset_run.name == result.run_name, "Dataset run should have correct name" - assert dataset_run.description == "Test on Langfuse dataset", ( - "Dataset run should have correct description" - ) - # Get dataset run items to verify trace linkage - dataset_run_items = api.dataset_run_items.list( - dataset_id=dataset.id, run_name=result.run_name +def test_same_run_name_on_dataset_reuses_experiment_id(): + """Re-running with the same run name on a dataset groups into one experiment.""" + langfuse_client = get_client() + dataset_name = "rerun-dataset-" + create_uuid() + langfuse_client.create_dataset(name=dataset_name) + langfuse_client.create_dataset_item( + dataset_name=dataset_name, input="Germany", expected_output="Berlin" ) - assert len(dataset_run_items.data) == 2, "Dataset run should have 2 items" + dataset = langfuse_client.get_dataset(dataset_name) + run_name = "Shared run " + create_uuid()[:8] - # Verify each dataset run item links to the correct trace - run_item_trace_ids = { - item.trace_id for item in dataset_run_items.data if item.trace_id - } - result_trace_ids = {item.trace_id for item in result.item_results} + first = dataset.run_experiment(name="Rerun", run_name=run_name, task=mock_task) + second = dataset.run_experiment(name="Rerun", run_name=run_name, task=mock_task) + langfuse_client.flush() - assert run_item_trace_ids == result_trace_ids, ( - f"Dataset run items should link to the same traces as experiment results. " - f"Run items: {run_item_trace_ids}, Results: {result_trace_ids}" - ) + assert first.experiment_id == second.experiment_id + items = get_experiment_items(first.experiment_id, expected_count=2) + assert {item.trace_id for item in items} == { + r.trace_id for r in first.item_results + second.item_results + } # Error Handling Tests @@ -630,20 +717,21 @@ def test_run_evaluator(**kwargs): run_evaluators=[test_run_evaluator], ) - assert result.dataset_run_id is not None assert len(result.item_results) == 1 assert len(result.run_evaluations) == 1 langfuse_client.flush() - time.sleep(3) - # Verify scores are persisted via API - api = get_api() - dataset_run = api.datasets.get_run( - dataset_name=dataset_name, run_name=result.run_name + experiment = get_experiment( + result.experiment_id, is_ready=has_score("persistence_run_test") ) + assert experiment.name == "Score persistence test" + run_scores = {s.name: s for s in experiment.scores or []} + assert run_scores["persistence_run_test"].value == 0.9 - assert dataset_run.name == "Score persistence test" + trace_scores = get_trace_scores(result.item_results[0].trace_id, expected_count=1) + assert [(s.name, s.value) for s in trace_scores] == [("persistence_test", 0.85)] + assert trace_scores[0].subject.kind == "observation" def test_multiple_experiments_on_same_dataset(): @@ -688,21 +776,22 @@ def test_multiple_experiments_on_same_dataset(): ) langfuse_client.flush() - time.sleep(2) - # Both experiments should have different run IDs - assert result1.dataset_run_id is not None - assert result2.dataset_run_id is not None - assert result1.dataset_run_id != result2.dataset_run_id + assert result1.experiment_id != result2.experiment_id - # Verify both runs exist in database api = get_api() - runs = api.datasets.get_runs(dataset_name) - assert len(runs.data) >= 2 - - run_names = [run.name for run in runs.data] - assert "Experiment 1" in run_names - assert "Experiment 2" in run_names + experiments = poll( + lambda: ( + api.experiments.list( + from_start_time=_read_window_start(), dataset_id=dataset.id + ).data + ), + lambda data: len(data) >= 2, + ) + assert {e.id: e.name for e in experiments} == { + result1.experiment_id: "Experiment 1", + result2.experiment_id: "Experiment 2", + } # Result Formatting Tests @@ -840,24 +929,24 @@ def mock_task_with_boolean_results(*, item: ExperimentItem, **kwargs): assert run_eval.value is False # Spain should fail, so not all pass assert run_eval.data_type == ScoreDataType.BOOLEAN - # Flush and wait for server processing langfuse_client.flush() - time.sleep(3) # Verify scores are persisted via API with correct data types for i, item_result in enumerate(result.item_results): trace_id = item_result.trace_id assert trace_id is not None, f"Item {i} should have a trace_id" - # Fetch trace from API to verify score persistence - trace = wait_for_trace( - trace_id, - is_result_ready=lambda trace: len(trace.scores) > 0, - ) - assert trace is not None, f"Trace {trace_id} should exist" + scores = get_trace_scores(trace_id, expected_count=1) + assert len(scores) == 1 + assert scores[0].data_type == "BOOLEAN" + assert scores[0].value is expected_results[i] - for score in trace.scores: - assert score.data_type == "BOOLEAN" + experiment = get_experiment( + result.experiment_id, is_ready=has_score("all_items_pass") + ) + run_score = next(s for s in experiment.scores or [] if s.name == "all_items_pass") + assert run_score.data_type == "BOOLEAN" + assert run_score.value is False def test_experiment_composite_evaluator_weighted_average(): diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index 3eb55ad40..02596c242 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -2,7 +2,7 @@ import inspect import typing -from datetime import datetime +from datetime import datetime, timezone from typing import get_type_hints from unittest.mock import MagicMock @@ -12,7 +12,9 @@ from langfuse import Evaluation, RegressionError, RunnerContext from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.client import Langfuse +from langfuse.api import DatasetItem, DatasetStatus from langfuse.batch_evaluation import CompositeEvaluatorFunction +from langfuse.experiment import ExperimentItemResult, ExperimentResult def _noop_task(*, item, **kwargs): # pragma: no cover - never invoked via mock @@ -524,3 +526,268 @@ def failing_evaluator(**kwargs): "failed_evaluator_count": 1, "skipped_evaluator_count": 1, } + + +def _dataset_item(*, item_id: str, dataset_id: str, input: str) -> DatasetItem: + return DatasetItem( + id=item_id, + status=DatasetStatus.ACTIVE, + input=input, + expected_output=f"expected {input}", + metadata=None, + source_trace_id=None, + source_observation_id=None, + dataset_id=dataset_id, + dataset_name="dataset", + media_references=[], + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + +def _run_scores(create_score: MagicMock) -> list: + return [ + call.kwargs + for call in create_score.call_args_list + if call.kwargs.get("dataset_run_id") is not None + ] + + +class TestExperimentRunIdentity: + @pytest.mark.parametrize( + ("project_id", "dataset_id", "run_name", "expected"), + [ + ("p", "d", "r", "eab007015b1c6f77"), + ("proj-ü", "ds", 'Run – ✓ "q"', "8d4425c67052ced3"), + # Shared with the JS SDK and the platform; keep these in sync. + ( + "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a", + "cm9x1dataset0000000000001", + "my-run", + "a1164c0f238e4173", + ), + ( + "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a", + "cm9x1dataset0000000000001", + 'Läufe "v2" 🚀 – 2026-10-06T12:00:00.000Z', + "dce21641128b88a8", + ), + ], + ) + def test_dataset_experiment_id_matches_server_derivation( + self, langfuse_memory_client, project_id, dataset_id, run_name, expected + ): + assert ( + langfuse_memory_client._create_experiment_id( + project_id=project_id, dataset_id=dataset_id, run_name=run_name + ) + == expected + ) + + def test_dataset_experiment_without_project_id_uses_random_id( + self, langfuse_memory_client, caplog + ): + ids = { + langfuse_memory_client._create_experiment_id( + project_id=None, dataset_id="d", run_name="r" + ) + for _ in range(2) + } + + assert len(ids) == 2 + assert all(len(i) == 16 for i in ids) + assert "random experiment id" in caplog.text + + def test_dataset_run_uses_stable_id_url_version_and_no_run_item_post( + self, langfuse_memory_client, find_spans, monkeypatch + ): + create_score = MagicMock() + create_run_item = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr( + langfuse_memory_client.api.dataset_run_items, "create", create_run_item + ) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + version = datetime(2026, 2, 3, 4, 5, 6, tzinfo=timezone.utc) + + result = langfuse_memory_client.run_experiment( + name="exp", + run_name="r", + data=[ + _dataset_item(item_id="i1", dataset_id="d", input="a"), + _dataset_item(item_id="i2", dataset_id="d", input="b"), + ], + task=lambda *, item, **kwargs: item.input, + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + _dataset_version=version, + ) + langfuse_memory_client.flush() + + create_run_item.assert_not_called() + assert result.experiment_id == "eab007015b1c6f77" + assert ( + result.experiment_url + == "http://test-host/project/p/experiments/results?baseline=eab007015b1c6f77" + ) + assert {r.experiment_id for r in result.item_results} == {result.experiment_id} + + spans = find_spans("experiment-item-run") + find_spans("experiment-item-task") + assert len(spans) == 4 + for span in spans: + assert ( + span.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ID] + == result.experiment_id + ) + assert ( + span.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_VERSION] + == "2026-02-03T04:05:06Z" + ) + + run_scores = _run_scores(create_score) + assert len(run_scores) == 1 + assert run_scores[0]["dataset_run_id"] == result.experiment_id + assert run_scores[0]["name"] == "run" + assert "trace_id" not in run_scores[0] + assert "observation_id" not in run_scores[0] + assert "session_id" not in run_scores[0] + + def test_local_run_persists_run_scores_without_item_version( + self, langfuse_memory_client, find_spans, monkeypatch + ): + create_score = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + + result = langfuse_memory_client.run_experiment( + name="exp", + run_name="r", + data=[{"input": "a"}], + task=lambda *, item, **kwargs: item["input"], + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + _dataset_version=datetime(2026, 2, 3, tzinfo=timezone.utc), + ) + langfuse_memory_client.flush() + + assert len(result.experiment_id) == 16 + assert result.experiment_id != "eab007015b1c6f77" + assert result.experiment_url == ( + f"http://test-host/project/p/experiments/results?baseline={result.experiment_id}" + ) + assert [s["dataset_run_id"] for s in _run_scores(create_score)] == [ + result.experiment_id + ] + for span in find_spans("experiment-item-run") + find_spans( + "experiment-item-task" + ): + assert LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_VERSION not in ( + span.attributes or {} + ) + + def test_unresolvable_project_id_yields_no_url( + self, langfuse_memory_client, monkeypatch + ): + def fail(): + raise RuntimeError("no network") + + monkeypatch.setattr(langfuse_memory_client, "create_score", MagicMock()) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", fail) + + result = langfuse_memory_client.run_experiment( + name="exp", + data=[_dataset_item(item_id="i1", dataset_id="d", input="a")], + task=lambda *, item, **kwargs: item.input, + ) + + assert len(result.item_results) == 1 + assert len(result.experiment_id) == 16 + assert result.experiment_url is None + + +class TestExperimentResultAliases: + def test_dataset_run_fields_are_deprecated_aliases(self): + item_result = ExperimentItemResult( + item={"input": "a"}, + output="a", + evaluations=[], + trace_id="t", + experiment_id="e", + ) + result = ExperimentResult( + name="exp", + run_name="r", + description=None, + item_results=[item_result], + run_evaluations=[], + experiment_id="e", + experiment_url="http://host/project/p/experiments/results?baseline=e", + ) + + with pytest.warns(DeprecationWarning, match="experiment_id"): + assert result.dataset_run_id == "e" + with pytest.warns(DeprecationWarning, match="experiment_url"): + assert result.dataset_run_url == result.experiment_url + with pytest.warns(DeprecationWarning, match="experiment_id"): + assert item_result.dataset_run_id == "e" + assert f"Experiment:\n {result.experiment_url}" in result.format() + + def test_legacy_constructor_arguments_still_populate_new_fields(self): + item_result = ExperimentItemResult( + item={"input": "a"}, + output="a", + evaluations=[], + trace_id="t", + dataset_run_id="legacy", + ) + result = ExperimentResult( + name="exp", + run_name="r", + description=None, + item_results=[item_result], + run_evaluations=[], + experiment_id="e", + dataset_run_url="http://legacy", + ) + + assert item_result.experiment_id == "legacy" + assert result.experiment_url == "http://legacy" + + +class TestExperimentRunScoreGating: + def test_run_scores_skipped_when_all_items_are_sampled_out( + self, langfuse_memory_client, monkeypatch + ): + from opentelemetry.sdk.trace.sampling import ALWAYS_OFF + + create_score = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + monkeypatch.setattr( + langfuse_memory_client._resources.tracer_provider, "sampler", ALWAYS_OFF + ) + monkeypatch.setattr(langfuse_memory_client._otel_tracer, "sampler", ALWAYS_OFF) + + result = langfuse_memory_client.run_experiment( + name="exp", + data=[{"input": "a"}, {"input": "b"}], + task=lambda *, item, **kwargs: item["input"], + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + ) + + assert [e.name for e in result.run_evaluations] == ["run"] + assert _run_scores(create_score) == [] + + def test_tracing_disabled_skips_project_id_lookup(self, monkeypatch): + client = Langfuse( + public_key="pk", secret_key="sk", base_url="http://x", tracing_enabled=False + ) + get_project_id = MagicMock(return_value="p") + monkeypatch.setattr(client, "_get_project_id", get_project_id) + + result = client.run_experiment( + name="exp", + data=[{"input": "a"}], + task=lambda *, item, **kwargs: item["input"], + ) + + get_project_id.assert_not_called() + assert result.experiment_url is None diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 2330269f0..77afcd7cf 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -2872,28 +2872,17 @@ def test_experiment_attributes_propagate_with_dataset( self, langfuse_client, memory_exporter, monkeypatch ): """Test experiment attribute propagation with Langfuse dataset.""" - created_run_items = [] - - # Mock the sync API used by run_experiment to create dataset run items - def mock_create_dataset_run_item(*args, **kwargs): - from langfuse.api import DatasetRunItem - - created_run_items.append(kwargs) - return DatasetRunItem( - id="mock-run-item-id", - dataset_run_id="mock-dataset-run-id-123", - dataset_run_name=kwargs.get("run_name", "Dataset Test"), - dataset_item_id=kwargs.get("dataset_item_id", "mock-item-id"), - trace_id="mock-trace-id", - observation_id=kwargs.get("observation_id"), - created_at=datetime.now(), - updated_at=datetime.now(), - ) + + def fail_create_dataset_run_item(*args, **kwargs): + raise AssertionError("run_experiment must not create dataset run items") monkeypatch.setattr( langfuse_client.api.dataset_run_items, "create", - mock_create_dataset_run_item, + fail_create_dataset_run_item, + ) + monkeypatch.setattr( + langfuse_client, "_get_project_id", lambda: "test-project-id" ) # Create a mock dataset with items @@ -2958,9 +2947,16 @@ def task_with_children(*, item, **kwargs): assert len(root_spans) >= 1, "Should have at least 1 root span" first_root = root_spans[0] task_span = self.get_span_by_name(memory_exporter, "experiment-item-task") - assert result.experiment_id == "mock-dataset-run-id-123" - assert len(created_run_items) == 1 - assert created_run_items[0]["observation_id"] == task_span["span_id"] + assert result.experiment_id == langfuse_client._create_experiment_id( + project_id="test-project-id", + dataset_id=dataset_id, + run_name=result.run_name, + ) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID, + task_span["span_id"], + ) # Root-only attributes should be on root self.verify_span_attribute( From 944be04eabd05b1596aed129d08295744425ad82 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:28:05 +0400 Subject: [PATCH 08/31] feat(langchain)!: require LangChain v1 (langchain-core >= 1) (#1940) * feat(langchain)!: drop LangChain v0 support The CallbackHandler now imports only from langchain_core and no longer requires the langchain package to be installed. Remove the langchain.__version__ branch and the langchain.callbacks / langchain.schema fallbacks for LangChain v0. The serializer fallback tests now hide langchain_core, the module the serializer actually imports, and reload the serializer module. Co-authored-by: Hassieb Pakzad * build(deps)!: require langchain-core>=1 and langchain-openai>=1 Add langchain-core>=1,<2 to the dev dependencies and raise langchain-openai from >=0.0.5,<0.4 to >=1,<2. The lock moves langchain-openai 0.3.34 -> 1.1.12, which keeps langchain-core 1.2.22 and openai 2.29.0 unchanged. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/langchain/CallbackHandler.py | 57 ++++++++------------------- pyproject.toml | 3 +- tests/unit/test_json.py | 34 ++++++++-------- uv.lock | 32 ++++++++------- 4 files changed, 51 insertions(+), 75 deletions(-) diff --git a/langfuse/langchain/CallbackHandler.py b/langfuse/langchain/CallbackHandler.py index f6126a066..0da6deb1e 100644 --- a/langfuse/langchain/CallbackHandler.py +++ b/langfuse/langchain/CallbackHandler.py @@ -38,50 +38,25 @@ from langfuse.types import TraceContext try: - import langchain - - if langchain.__version__.startswith("1"): - # Langchain v1 - from langchain_core.agents import AgentAction, AgentFinish - from langchain_core.callbacks import ( - BaseCallbackHandler as LangchainBaseCallbackHandler, - ) - from langchain_core.documents import Document - from langchain_core.messages import ( - AIMessage, - BaseMessage, - ChatMessage, - FunctionMessage, - HumanMessage, - SystemMessage, - ToolMessage, - ) - from langchain_core.outputs import ChatGeneration, LLMResult - - else: - # Langchain v0 - from langchain.callbacks.base import ( # type: ignore - BaseCallbackHandler as LangchainBaseCallbackHandler, - ) - from langchain.schema.agent import AgentAction, AgentFinish # type: ignore - from langchain.schema.document import Document # type: ignore - from langchain_core.messages import ( - AIMessage, - BaseMessage, - ChatMessage, - FunctionMessage, - HumanMessage, - SystemMessage, - ToolMessage, - ) - from langchain_core.outputs import ( - ChatGeneration, - LLMResult, - ) + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.callbacks import ( + BaseCallbackHandler as LangchainBaseCallbackHandler, + ) + from langchain_core.documents import Document + from langchain_core.messages import ( + AIMessage, + BaseMessage, + ChatMessage, + FunctionMessage, + HumanMessage, + SystemMessage, + ToolMessage, + ) + from langchain_core.outputs import ChatGeneration, LLMResult except ImportError: raise ModuleNotFoundError( - "Please install langchain to use the Langfuse langchain integration: 'pip install langchain'" + "Please install langchain-core>=1 to use the Langfuse langchain integration: 'pip install \"langchain-core>=1\"'" ) LANGSMITH_TAG_HIDDEN: str = "langsmith:hidden" diff --git a/pyproject.toml b/pyproject.toml index 743bc32e6..00df80e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,8 @@ dev = [ "ruff>=0.15.2,<0.16", "mypy>=1.0.0,<2", "openai>=1.0.0", - "langchain-openai>=0.0.5,<0.4", + "langchain-core>=1,<2", + "langchain-openai>=1,<2", "langchain>=1,<2", "langgraph>=1,<2", "autoevals>=0.0.130,<0.1", diff --git a/tests/unit/test_json.py b/tests/unit/test_json.py index 1f7ef6ece..5f46f16ae 100644 --- a/tests/unit/test_json.py +++ b/tests/unit/test_json.py @@ -10,7 +10,7 @@ from langchain.messages import HumanMessage from pydantic import BaseModel -import langfuse +import langfuse._utils.serializer as serializer_module from langfuse._utils.serializer import EventSerializer from langfuse.api import ObservationLevel @@ -61,41 +61,39 @@ def test_json_decoder_without_langchain_serializer(): @pytest.fixture -def hide_available_langchain(monkeypatch): +def serializer_without_langchain(monkeypatch): import_orig = builtins.__import__ def mocked_import(name, *args, **kwargs): - if name == "langchain" or name == "langchain.load.serializable": + if name == "langchain_core" or name.startswith("langchain_core."): raise ImportError() return import_orig(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", mocked_import) + yield importlib.reload(serializer_module) + monkeypatch.undo() + importlib.reload(serializer_module) -@pytest.mark.usefixtures("hide_available_langchain") -def test_json_decoder_without_langchain_serializer_with_langchain_message(): - with pytest.raises(ImportError): - import langchain # noqa +def test_json_decoder_without_langchain_serializer_with_langchain_message( + serializer_without_langchain, +): with pytest.raises(ImportError): - from langchain.load.serializable import Serializable # noqa + from langchain_core.load.serializable import Serializable # noqa - importlib.reload(langfuse) obj = TestModel(foo="bar", bar=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - result = json.dumps(obj, cls=EventSerializer) + result = json.dumps(obj, cls=serializer_without_langchain.EventSerializer) assert result == '{"foo": "bar", "bar": "2021-01-01T00:00:00Z"}' -@pytest.mark.usefixtures("hide_available_langchain") -def test_json_decoder_without_langchain_serializer_with_none(): - with pytest.raises(ImportError): - import langchain # noqa - +def test_json_decoder_without_langchain_serializer_with_none( + serializer_without_langchain, +): with pytest.raises(ImportError): - from langchain.load.serializable import Serializable # noqa + from langchain_core.load.serializable import Serializable # noqa - importlib.reload(langfuse) - result = json.dumps(None, cls=EventSerializer) + result = json.dumps(None, cls=serializer_without_langchain.EventSerializer) default = json.dumps(None) assert result == "null" assert result == default diff --git a/uv.lock b/uv.lock index 701ea859a..8e1fd8ec5 100644 --- a/uv.lock +++ b/uv.lock @@ -3,20 +3,20 @@ revision = 3 requires-python = ">=3.11, <4.0" [options] -exclude-newer = "2026-09-28T08:53:26.348016113Z" +exclude-newer = "2026-09-30T09:24:24.1987207Z" exclude-newer-span = "P7D" [options.exclude-newer-package] -opentelemetry-sdk = { timestamp = "2026-10-02T08:53:26.348034988Z", span = "P3D" } -opentelemetry-exporter-otlp-proto-common = { timestamp = "2026-10-02T08:53:26.348036791Z", span = "P3D" } -opentelemetry-api = { timestamp = "2026-10-02T08:53:26.348028349Z", span = "P3D" } -opentelemetry-instrumentation-threading = { timestamp = "2026-10-02T08:53:26.348039705Z", span = "P3D" } -opentelemetry-exporter-otlp-proto-http = { timestamp = "2026-10-02T08:53:26.348037311Z", span = "P3D" } -opentelemetry-proto = { timestamp = "2026-10-02T08:53:26.34803612Z", span = "P3D" } -opentelemetry-exporter-http-transport = { timestamp = "2026-10-02T08:53:26.348038423Z", span = "P3D" } -opentelemetry-semantic-conventions = { timestamp = "2026-10-02T08:53:26.348035579Z", span = "P3D" } -opentelemetry-exporter-otlp-common = { timestamp = "2026-10-02T08:53:26.348037912Z", span = "P3D" } -opentelemetry-instrumentation = { timestamp = "2026-10-02T08:53:26.348039134Z", span = "P3D" } +opentelemetry-sdk = { timestamp = "2026-10-04T09:24:24.198747432Z", span = "P3D" } +opentelemetry-exporter-otlp-proto-common = { timestamp = "2026-10-04T09:24:24.198748763Z", span = "P3D" } +opentelemetry-api = { timestamp = "2026-10-04T09:24:24.198731079Z", span = "P3D" } +opentelemetry-instrumentation-threading = { timestamp = "2026-10-04T09:24:24.198751113Z", span = "P3D" } +opentelemetry-exporter-otlp-proto-http = { timestamp = "2026-10-04T09:24:24.198749247Z", span = "P3D" } +opentelemetry-proto = { timestamp = "2026-10-04T09:24:24.198748273Z", span = "P3D" } +opentelemetry-exporter-http-transport = { timestamp = "2026-10-04T09:24:24.198750056Z", span = "P3D" } +opentelemetry-semantic-conventions = { timestamp = "2026-10-04T09:24:24.198747912Z", span = "P3D" } +opentelemetry-exporter-otlp-common = { timestamp = "2026-10-04T09:24:24.198749653Z", span = "P3D" } +opentelemetry-instrumentation = { timestamp = "2026-10-04T09:24:24.19875072Z", span = "P3D" } [[package]] name = "annotated-types" @@ -490,16 +490,16 @@ wheels = [ [[package]] name = "langchain-openai" -version = "0.3.34" +version = "1.1.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "openai" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/23/91461c6c2b1c8ceeadd6fefe453a03b1537afeff4ace3c118d7e7659f5a7/langchain_openai-0.3.34.tar.gz", hash = "sha256:57916d462be5b8fd19e5cb2f00d4e5cf0465266a292d583de2fc693a55ba190e", size = 785924, upload-time = "2025-10-01T15:18:40.635Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/fd/7dee16e882c4c1577d48db174d85aa3a0ee09ba61eb6a5d41650285ca80c/langchain_openai-1.1.12.tar.gz", hash = "sha256:ccf5ef02c896f6807b4d0e51aaf678a72ce81ae41201cae8d65e11eeff9ecb79", size = 1114119, upload-time = "2026-03-23T18:59:19.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/94/132aff5a1ed4ebfe7b4d3e4dbf30b9f70ce2bd2ac3470d0c0e742845afb1/langchain_openai-0.3.34-py3-none-any.whl", hash = "sha256:08d61d68a6d869c70d542171e149b9065668dedfc4fafcd4de8aeb5b933030a9", size = 75605, upload-time = "2025-10-01T15:18:39.415Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/68fb22e3604015e6f546fa1d3677d24378b482855ae74710cbf4aec44132/langchain_openai-1.1.12-py3-none-any.whl", hash = "sha256:da71ca3f2d18c16f7a2443cc306aa195ad2a07054335ac9b0626dcae02b6a0c5", size = 88487, upload-time = "2026-03-23T18:59:17.978Z" }, ] [[package]] @@ -522,6 +522,7 @@ dependencies = [ dev = [ { name = "autoevals" }, { name = "langchain" }, + { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, { name = "mypy" }, @@ -557,7 +558,8 @@ requires-dist = [ dev = [ { name = "autoevals", specifier = ">=0.0.130,<0.1" }, { name = "langchain", specifier = ">=1,<2" }, - { name = "langchain-openai", specifier = ">=0.0.5,<0.4" }, + { name = "langchain-core", specifier = ">=1,<2" }, + { name = "langchain-openai", specifier = ">=1,<2" }, { name = "langgraph", specifier = ">=1,<2" }, { name = "mypy", specifier = ">=1.0.0,<2" }, { name = "openai", specifier = ">=1.0.0" }, From b4c284b1c6521e59806a0c3f0296aa22e6a363ef Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:30:50 +0400 Subject: [PATCH 09/31] feat(batch-evaluation)!: evaluate observations via the v2 observations API (#1936) * chore!: require Python 3.11 or newer Python 3.10 reaches end of life in October 2026. Raise requires-python to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and typing.NotRequired. Co-authored-by: Hassieb Pakzad * feat(openai)!: drop support for OpenAI SDK versions below 1.0 The OpenAI integration now always uses the v1 client shapes. Remove the pre-1.0 method definitions and every _is_openai_v1() branch, and raise the dev dependency floor to openai>=1.0.0. Co-authored-by: Hassieb Pakzad * feat(otel)!: send x-langfuse-ingestion-version: 4 by default The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so servers in dual-write or preview mode process exported spans directly. It is a no-op on events_only deployments, and additional_headers can still override it. Co-authored-by: Hassieb Pakzad * feat(client)!: remove blocked_instrumentation_scopes blocked_instrumentation_scopes was deprecated in favor of should_export_span. Remove it from Langfuse, the resource manager and the span processor, and drop the leftover is_active entry from the create_prompt docstring. LANGFUSE_HOST and host keep working, but now log a deprecation warning when they decide the base URL. Co-authored-by: Hassieb Pakzad * feat(tracing)!: JSON-serialize propagated metadata values propagate_attributes(metadata=...) coerced non-string values with str(), so a boolean became "True" and a list became its Python repr. Serialize them as JSON instead, like observation metadata, and drop None values instead of sending "None". Co-authored-by: Hassieb Pakzad * feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run These helpers call the dataset-run endpoints that Langfuse v4 no longer serves, so they always fail with a 404. Read experiment runs through langfuse.api.experiments.list() and list_items() instead. Co-authored-by: Hassieb Pakzad * feat(experiments)!: record experiment runs via OTEL only Generate the experiment id client-side instead of creating dataset run items via POST /dataset-run-items. Dataset-backed runs derive a stable id from project, dataset and run name (matching the server's derivation), so re-runs with the same run name keep grouping into one experiment. Local runs keep a random id. - set langfuse.experiment.item.version from the pinned dataset version - always persist run evaluations as scores on the experiment, incl. local data - return experiment_id / experiment_url (v4 results page) on results; dataset_run_id / dataset_run_url remain as deprecated aliases BREAKING CHANGE: run_experiment no longer creates Postgres dataset runs; dataset_run_id / dataset_run_url are deprecated aliases that are now also set for local-data experiments, and the URL points to the experiments page. Co-authored-by: Hassieb Pakzad * test(experiments): read experiment e2e results via v4 APIs Replace dataset run, dataset run item and legacy trace reads with the experiments, observations and v3 scores APIs, and cover stable experiment ids, run-level scores for local data and the recorded item version. Co-authored-by: Hassieb Pakzad * test(experiments): pin the cross-SDK experiment id vectors Co-authored-by: Hassieb Pakzad * feat(batch-evaluation)!: evaluate observations via the v2 observations API Batch evaluation now reads exclusively from GET /api/public/v2/observations with cursor pagination, so it works on Langfuse platform v4. BREAKING CHANGES: - scope="traces" is replaced by scope="root_observations", which evaluates the logical root observation of each trace and attaches scores to the trace. scope="observations" is unchanged in intent. - Mappers receive an ObservationV2. input/output are raw strings and only the requested field groups are populated. - fetch_trace_fields is replaced by fields (v2 field groups), defaulting to "core,basic,io,metadata" in both the client and the runner. - filter must be a JSON array in the v2 observations filter format. - Resume tokens carry the pagination cursor and are also returned when max_items is reached while more items exist. - The private _additional_trace_tags option is removed. Co-authored-by: Hassieb Pakzad * test(batch-evaluation): rewrite e2e suite for v2 observations Co-authored-by: Hassieb Pakzad * fix(tracing): serialize propagated metadata like JSON.stringify Use compact separators, keep non-ASCII characters, and send None as "null", so the Python and JS SDKs emit byte-identical propagated metadata values. Co-authored-by: Hassieb Pakzad * fix(observe): keep detecting legacy-marked coroutine functions inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker, which asgiref's markcoroutinefunction sets on Python < 3.12. Accept both so such functions keep the async wrapper and their observation ends after the coroutine runs. Co-authored-by: Hassieb Pakzad * test(otel): pin that a mixed-case ingestion-version override wins Co-authored-by: Hassieb Pakzad * fix(tracing): keep the exact digits of large integer metadata values Co-authored-by: Hassieb Pakzad * fix(experiments): skip orphaned run scores and offline project lookups Run-level scores are only sent when at least one item trace is sampled, because a fully sampled-out experiment never reaches the server. Clients with tracing disabled no longer resolve the project id. Co-authored-by: Hassieb Pakzad * fix(batch-evaluation): one root per trace and safe resume - root_observations scope evaluates each trace once, preferring the physical root over SDK-marked app roots - resuming reuses the token's filter and rejects a different one, since the cursor only continues the query that produced it - the cursorless start-time fallback is inclusive so tied observations are not skipped Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * fix(tracing): match JS for nested big ints and non-finite metadata Integers keep their exact digits as JSON numbers at any depth, and propagated metadata values containing NaN or Infinity are dropped with a warning instead of being stored, matching the JS SDK. Co-authored-by: Hassieb Pakzad * feat(client): keep host and LANGFUSE_HOST working without a warning Co-authored-by: Hassieb Pakzad * feat(tracing)!: remove trace-level input/output setters (#1935) Remove the deprecated Langfuse.set_current_trace_io() and span.set_trace_io(), the langfuse.trace.input/output attributes, and their media handling. On Langfuse v4 a trace's input and output come from its root observation: set them with update() on the root span or update_current_span(). Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad * test: run the e2e suites on Langfuse v4 events_only (#1937) * feat(tracing)!: remove trace-level input/output setters Remove the deprecated Langfuse.set_current_trace_io() and span.set_trace_io(), the langfuse.trace.input/output attributes, and their media handling. On Langfuse v4 a trace's input and output come from its root observation: set them with update() on the root span or update_current_span(). Co-authored-by: Hassieb Pakzad * test: read e2e assertions through the v4 observations API Co-authored-by: Hassieb Pakzad * ci: run e2e against an events_only server Co-authored-by: Hassieb Pakzad * test: pin that create_score logs invalid input without enqueueing Co-authored-by: Hassieb Pakzad * test: remove the v3 wait_for_trace helper Co-authored-by: Hassieb Pakzad * test: keep scalar text IO as strings and read full response_format The v2 observations API returns IO as raw strings, so only JSON objects and arrays are parsed back; text like "2" stays a string. The response_format assertion requests the untruncated metadata value. Co-authored-by: Hassieb Pakzad * test: dedupe observation rows and assert streamed text as a string The events table can briefly return several rows for one span, so the observation helper keeps the latest row per id. A streamed completion of "2" is text and is now asserted as a string. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad * feat(batch-evaluation)!: evaluate observations only Remove the scope parameter and root-observation mode, so batch evaluation always runs on the observations matching the filter and scores each observation. Resume tokens no longer carry a scope. Co-authored-by: Hassieb Pakzad * fix(batch-evaluation): evaluate duplicate observation rows once The events table can briefly return several rows for one observation, within a page or across a page boundary. Keep the most recently updated row per id so each observation is evaluated and scored once. Also build exception messages before raising. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- .github/langfuse-v4-dual-write.override.yml | 7 - .github/workflows/ci.yml | 2 - langfuse/_client/attributes.py | 6 - langfuse/_client/client.py | 221 +-- langfuse/_client/span.py | 48 - langfuse/_client/span_exporter.py | 2 - langfuse/batch_evaluation.py | 829 +++++------ tests/e2e/test_batch_evaluation.py | 1281 +++-------------- tests/e2e/test_core_sdk.py | 852 +++++------ tests/e2e/test_decorators.py | 119 +- tests/e2e/test_media.py | 31 +- tests/e2e/test_prompt.py | 26 +- tests/live_provider/test_langchain.py | 66 +- .../test_langchain_integration.py | 236 ++- tests/live_provider/test_openai.py | 851 +++++------ tests/support/api_wrapper.py | 130 -- tests/support/utils.py | 345 ++++- tests/unit/test_batch_evaluation.py | 380 +++++ tests/unit/test_e2e_support.py | 320 ++-- tests/unit/test_mask_otel_spans.py | 2 - tests/unit/test_otel.py | 8 +- tests/unit/test_resource_manager.py | 31 + 22 files changed, 2550 insertions(+), 3243 deletions(-) delete mode 100644 .github/langfuse-v4-dual-write.override.yml delete mode 100644 tests/support/api_wrapper.py create mode 100644 tests/unit/test_batch_evaluation.py diff --git a/.github/langfuse-v4-dual-write.override.yml b/.github/langfuse-v4-dual-write.override.yml deleted file mode 100644 index 48ab6ff48..000000000 --- a/.github/langfuse-v4-dual-write.override.yml +++ /dev/null @@ -1,7 +0,0 @@ -services: - langfuse-worker: - environment: - LANGFUSE_MIGRATION_V4_WRITE_MODE: dual - langfuse-web: - environment: - LANGFUSE_MIGRATION_V4_WRITE_MODE: dual diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 104cda66f..ee355e49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,8 +159,6 @@ jobs: LANGFUSE_SERVER_SHA="$(git ls-remote https://github.com/langfuse/langfuse.git HEAD | cut -f1)" curl -fsSL "https://raw.githubusercontent.com/langfuse/langfuse/${LANGFUSE_SERVER_SHA}/docker-compose.yml" \ -o ./langfuse-server/docker-compose.yml - cp ./.github/langfuse-v4-dual-write.override.yml \ - ./langfuse-server/docker-compose.override.yml echo "${LANGFUSE_SERVER_SHA}" - name: Run langfuse server diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index 43a85c2fd..e7027a192 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -32,8 +32,6 @@ class LangfuseOtelSpanAttributes: TRACE_TAGS = "langfuse.trace.tags" TRACE_PUBLIC = "langfuse.trace.public" TRACE_METADATA = "langfuse.trace.metadata" - TRACE_INPUT = "langfuse.trace.input" - TRACE_OUTPUT = "langfuse.trace.output" # Langfuse-observation attributes OBSERVATION_TYPE = "langfuse.observation.type" @@ -76,13 +74,9 @@ class LangfuseOtelSpanAttributes: def create_trace_attributes( *, - input: Optional[Any] = None, - output: Optional[Any] = None, public: Optional[bool] = None, ) -> dict: attributes = { - LangfuseOtelSpanAttributes.TRACE_INPUT: _serialize(input), - LangfuseOtelSpanAttributes.TRACE_OUTPUT: _serialize(output), LangfuseOtelSpanAttributes.TRACE_PUBLIC: public, } diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index dbcb8c9ee..fe3b09426 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -38,7 +38,6 @@ _agnosticcontextmanager, ) from packaging.version import Version -from typing_extensions import deprecated from langfuse._client.attributes import ( LangfuseOtelSpanAttributes, @@ -213,7 +212,7 @@ class Langfuse: release (Optional[str]): Release version/hash of your application. Used for grouping analytics by release. media_upload_thread_count (Optional[int]): Number of background threads for handling media uploads. Defaults to 1. Can also be set via LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT environment variable. sample_rate (Optional[float]): Sampling rate for traces (0.0 to 1.0). Defaults to 1.0 (100% of traces are sampled). Can also be set via LANGFUSE_SAMPLE_RATE environment variable. - mask (Optional[MaskFunction]): Function to mask sensitive data synchronously when Langfuse SDK attributes are created. This applies only to data set through Langfuse SDK APIs such as `start_observation()`, `update()`, and `set_trace_io()`. + mask (Optional[MaskFunction]): Function to mask sensitive data synchronously when Langfuse SDK attributes are created. This applies only to data set through Langfuse SDK APIs such as `start_observation()` and `update()`. mask_otel_spans (Optional[MaskOtelSpansFunction]): Synchronous export-stage hook for masking raw OpenTelemetry span attributes before this Langfuse client sends them to Langfuse. Use this for spans created by third-party OpenTelemetry instrumentations, or when you need to inspect final span attributes after export filtering and Langfuse media handling. It does not modify spans already exported through other OpenTelemetry exporters. The hook receives one OpenTelemetry export batch. A batch is not guaranteed to contain a complete trace, request, or Langfuse observation tree. The hook usually runs on the OpenTelemetry batch span processor worker thread; during `flush()` and shutdown it may run on the caller thread. Keep it synchronous, deterministic, and fast. @@ -1534,55 +1533,6 @@ def update_current_span( status_message=status_message, ) - @deprecated( - "Trace-level input/output is deprecated. " - "For trace attributes (user_id, session_id, tags, etc.), use propagate_attributes() instead. " - "This method will be removed in a future major version." - ) - def set_current_trace_io( - self, - *, - input: Optional[Any] = None, - output: Optional[Any] = None, - ) -> None: - """Set trace-level input and output for the current span's trace. - - .. deprecated:: - This is a legacy method for backward compatibility with Langfuse platform - features that still rely on trace-level input/output (e.g., legacy LLM-as-a-judge - evaluators). It will be removed in a future major version. - - For setting other trace attributes (user_id, session_id, metadata, tags, version), - use :func:`langfuse.propagate_attributes` (top-level import) instead. - - Args: - input: Input data to associate with the trace. - output: Output data to associate with the trace. - """ - if not self._tracing_enabled: - langfuse_logger.debug( - "Operation skipped: set_current_trace_io - Tracing is disabled or client is in no-op mode." - ) - return - - current_otel_span = self._get_current_otel_span() - - if current_otel_span is not None and current_otel_span.is_recording(): - span_class = self._get_span_class( - self._get_observation_type_from_otel_span(current_otel_span) - ) - span = span_class( - otel_span=current_otel_span, - langfuse_client=self, - environment=self._environment, - release=self._release, - ) - - span.set_trace_io( - input=input, - output=output, - ) - def set_current_trace_as_public(self) -> None: """Make the current trace publicly accessible via its URL. @@ -3203,11 +3153,10 @@ def _create_experiment_run_name( def run_batched_evaluation( self, *, - scope: Literal["traces", "observations"], mapper: MapperFunction, filter: Optional[str] = None, fetch_batch_size: int = 50, - fetch_trace_fields: Optional[str] = None, + fields: Optional[str] = "core,basic,io,metadata", max_items: Optional[int] = None, max_retries: int = 3, evaluators: List[EvaluatorFunction], @@ -3215,16 +3164,15 @@ def run_batched_evaluation( max_concurrency: int = 5, metadata: Optional[Dict[str, Any]] = None, _add_observation_scores_to_trace: bool = False, - _additional_trace_tags: Optional[List[str]] = None, resume_from: Optional[BatchEvaluationResumeToken] = None, verbose: bool = False, ) -> BatchEvaluationResult: - """Fetch traces or observations using legacy read APIs and evaluate each item. + """Fetch observations from Langfuse and evaluate each of them. This method provides a powerful way to evaluate existing data in Langfuse at scale. - It fetches items based on filters, transforms them using a mapper function, runs - evaluators on each item, and creates scores that are linked back to the original - entities. This is ideal for: + It fetches observations based on filters, transforms them using a mapper function, + runs evaluators on each item, and creates scores that are linked back to the + original entities. This is ideal for: - Running evaluations on production traces after deployment - Backtesting new evaluation metrics on historical data @@ -3235,46 +3183,51 @@ def run_batched_evaluation( it memory-efficient for large datasets. It includes comprehensive error handling, retry logic, and resume capability for long-running evaluations. - Legacy platform compatibility: - This method reads traces from `GET /api/public/traces` and observations - from the legacy `GET /api/public/observations` endpoint. It is supported - with Langfuse platform v3 and is not yet supported with platform v4. + Items are read from `GET /api/public/v2/observations` with cursor pagination, + newest first (by start time). Args: - scope: The type of items to evaluate. Must be one of: - - "traces": Evaluate complete traces with all their observations - - "observations": Evaluate individual observations (spans, generations, events) - mapper: Function that transforms API response objects into evaluator inputs. - Receives a trace/observation object and returns an EvaluatorInputs + mapper: Function that transforms an `ObservationV2` into evaluator inputs. + Called as `mapper(item=observation)` and must return an EvaluatorInputs instance with input, output, expected_output, and metadata fields. - Can be sync or async. + `input`/`output` are raw strings (not JSON-parsed), and only the + requested `fields` groups are populated. Can be sync or async. evaluators: List of evaluation functions to run on each item. Each evaluator receives the mapped inputs and returns Evaluation object(s). Evaluator failures are logged but don't stop the batch evaluation. - filter: Optional JSON filter string for querying items (same format as Langfuse API). Examples: - - '{"tags": ["production"]}' - - '{"user_id": "user123", "timestamp": {"operator": ">", "value": "2024-01-01"}}' - Default: None (fetches all items). + filter: Optional JSON array of filter conditions in the v2 observations + filter format, for example: + - '[{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["production"]}]' + - '[{"type": "string", "column": "traceName", "operator": "=", "value": "chat"}]' + - '[{"type": "datetime", "column": "startTime", "operator": ">=", "value": "2026-01-01T00:00:00Z"}]' + Default: None (fetches all observations). fetch_batch_size: Number of items to fetch per API call and hold in memory. - Larger values may be faster but use more memory. Default: 50. - fetch_trace_fields: Comma-separated list of fields to include when fetching traces. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. Only relevant if scope is 'traces'. + Larger values may be faster but use more memory. Maximum 1000. Default: 50. + fields: Comma-separated list of observation field groups to fetch. Available + groups: 'core' (always included), 'basic', 'time', 'io', 'metadata', + 'model', 'usage', 'prompt', 'metrics', 'trace_context'. Fields of groups + that are not requested are None on the item passed to the mapper. + Metadata values longer than 200 characters are truncated. + Default: "core,basic,io,metadata". max_items: Maximum total number of items to process. If None, processes all items matching the filter. Useful for testing or limiting evaluation runs. Default: None (process all). max_concurrency: Maximum number of items to evaluate concurrently. Controls parallelism and resource usage. Default: 5. composite_evaluator: Optional function that creates a composite score from - item-level evaluations. Receives the original item and its evaluations, - returns a single Evaluation. Useful for weighted averages or combined metrics. - Default: None. + item-level evaluations. Receives the mapped inputs and the item's + evaluations, returns Evaluation(s). Useful for weighted averages or + combined metrics. Default: None. metadata: Optional metadata dict to add to all created scores. Useful for tracking evaluation runs, versions, or other context. Default: None. max_retries: Maximum number of retry attempts for failed batch fetches. - Uses exponential backoff (1s, 2s, 4s). Default: 3. + Default: 3. verbose: If True, logs progress information to console. Useful for monitoring long-running evaluations. Default: False. - resume_from: Optional resume token from a previous incomplete run. Allows - continuing evaluation after interruption or failure. Default: None. + resume_from: Optional resume token from a previous run that stopped early + (fetch failure or `max_items`). Continues exactly after the last + processed page. Pass the same `filter`, or omit it to reuse the + token's filter. Default: None. Returns: @@ -3286,134 +3239,112 @@ def run_batched_evaluation( - total_composite_scores_created: Scores created by composite evaluator - total_evaluations_failed: Individual evaluator failures - evaluator_stats: Per-evaluator statistics (success rate, scores created) - - resume_token: Token for resuming if incomplete (None if completed) - - completed: True if all items processed + - resume_token: Token for continuing the run (set after a fetch failure + or when max_items was reached while more items exist) + - completed: False if the run stopped because a fetch failed - duration_seconds: Total execution time - - failed_item_ids: IDs of items that failed + - failed_item_ids: Observation IDs of items that failed - error_summary: Error types and counts - has_more_items: True if max_items reached but more exist + - item_evaluations: Evaluations per observation ID Raises: - ValueError: If invalid scope is provided. + ValueError: If a non-array filter is provided, or the resume token was + created for a different filter. Examples: - Basic trace evaluation: + Evaluate production observations: ```python from langfuse import Langfuse, EvaluatorInputs, Evaluation client = Langfuse() - # Define mapper to extract fields from traces - def trace_mapper(trace): + def simple_mapper(*, item): return EvaluatorInputs( - input=trace.input, - output=trace.output, + input=item.input, + output=item.output, expected_output=None, - metadata={"trace_id": trace.id} + metadata={"trace_id": item.trace_id}, ) - # Define evaluator def length_evaluator(*, input, output, expected_output, metadata): return Evaluation( name="output_length", value=len(output) if output else 0 ) - # Run batch evaluation result = client.run_batched_evaluation( - scope="traces", - mapper=trace_mapper, + mapper=simple_mapper, evaluators=[length_evaluator], - filter='{"tags": ["production"]}', + filter='[{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["production"]}]', max_items=1000, verbose=True ) - print(f"Processed {result.total_items_processed} traces") + print(f"Processed {result.total_items_processed} observations") print(f"Created {result.total_scores_created} scores") ``` - Evaluation with composite scorer: + Evaluate generations with a composite scorer: ```python + import json + + def generation_mapper(*, item): + return EvaluatorInputs( + input=json.loads(item.input) if item.input else None, + output=item.output, + expected_output=None, + metadata={"model": item.model}, + ) + def accuracy_evaluator(*, input, output, expected_output, metadata): - # ... evaluation logic return Evaluation(name="accuracy", value=0.85) def relevance_evaluator(*, input, output, expected_output, metadata): - # ... evaluation logic return Evaluation(name="relevance", value=0.92) - def composite_evaluator(*, item, evaluations): - # Weighted average of evaluations + def composite_evaluator(*, input, output, expected_output, metadata, evaluations): weights = {"accuracy": 0.6, "relevance": 0.4} total = sum( e.value * weights.get(e.name, 0) for e in evaluations if isinstance(e.value, (int, float)) ) - return Evaluation( - name="composite_score", - value=total, - comment=f"Weighted average of {len(evaluations)} metrics" - ) + return Evaluation(name="composite_score", value=total) result = client.run_batched_evaluation( - scope="traces", - mapper=trace_mapper, + mapper=generation_mapper, evaluators=[accuracy_evaluator, relevance_evaluator], composite_evaluator=composite_evaluator, - filter='{"user_id": "important_user"}', - verbose=True + filter='[{"type": "string", "column": "type", "operator": "=", "value": "GENERATION"}]', + fields="core,basic,io,model", ) ``` - Handling incomplete runs with resume: + Continuing a run that stopped early: ```python - # Initial run that may fail or timeout result = client.run_batched_evaluation( - scope="observations", - mapper=obs_mapper, - evaluators=[my_evaluator], + mapper=generation_mapper, + evaluators=[accuracy_evaluator], max_items=10000, - verbose=True ) - # Check if incomplete - if not result.completed and result.resume_token: - print(f"Processed {result.resume_token.items_processed} items before interruption") - - # Resume from where it left off + while result.resume_token: result = client.run_batched_evaluation( - scope="observations", - mapper=obs_mapper, - evaluators=[my_evaluator], + mapper=generation_mapper, + evaluators=[accuracy_evaluator], + max_items=10000, resume_from=result.resume_token, - verbose=True ) - - print(f"Total items processed: {result.total_items_processed}") - ``` - - Monitoring evaluator performance: - ```python - result = client.run_batched_evaluation(...) - - for stats in result.evaluator_stats: - success_rate = stats.successful_runs / stats.total_runs - print(f"{stats.name}:") - print(f" Success rate: {success_rate:.1%}") - print(f" Scores created: {stats.total_scores_created}") - - if stats.failed_runs > 0: - print(f" ⚠️ Failed {stats.failed_runs} times") ``` Note: - Evaluator failures are logged but don't stop the batch evaluation - Individual item failures are tracked but don't stop processing - - Fetch failures are retried with exponential backoff + - Fetch failures are retried up to `max_retries` times - All scores are automatically flushed to Langfuse at the end - - The resume mechanism uses timestamp-based filtering to avoid duplicates + - Resuming uses the pagination cursor, so items are neither skipped nor + evaluated twice """ runner = BatchEvaluationRunner(self) @@ -3421,18 +3352,16 @@ def composite_evaluator(*, item, evaluations): BatchEvaluationResult, run_async_safely( runner.run_async( - scope=scope, mapper=mapper, evaluators=evaluators, filter=filter, fetch_batch_size=fetch_batch_size, - fetch_trace_fields=fetch_trace_fields, + fields=fields, max_items=max_items, max_concurrency=max_concurrency, composite_evaluator=composite_evaluator, metadata=metadata, _add_observation_scores_to_trace=_add_observation_scores_to_trace, - _additional_trace_tags=_additional_trace_tags, max_retries=max_retries, verbose=verbose, resume_from=resume_from, diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 96879499c..9e2b5b718 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -36,7 +36,6 @@ if TYPE_CHECKING: from langfuse._client.client import Langfuse -from typing_extensions import deprecated from langfuse._client.attributes import ( LangfuseOtelSpanAttributes, @@ -226,53 +225,6 @@ def end(self, *, end_time: Optional[int] = None) -> "LangfuseObservationWrapper" return self - @deprecated( - "Trace-level input/output is deprecated. " - "For trace attributes (user_id, session_id, tags, etc.), use propagate_attributes() instead. " - "This method will be removed in a future major version." - ) - def set_trace_io( - self, - *, - input: Optional[Any] = None, - output: Optional[Any] = None, - ) -> "LangfuseObservationWrapper": - """Set trace-level input and output for the trace this span belongs to. - - .. deprecated:: - This is a legacy method for backward compatibility with Langfuse platform - features that still rely on trace-level input/output (e.g., legacy LLM-as-a-judge - evaluators). It will be removed in a future major version. - - For setting other trace attributes (user_id, session_id, metadata, tags, version), - use :func:`langfuse.propagate_attributes` (top-level import) instead. - - Args: - input: Input data to associate with the trace. - output: Output data to associate with the trace. - - Returns: - The span instance for method chaining. - """ - if not self._otel_span.is_recording(): - return self - - media_processed_input = self._process_media_and_apply_mask( - data=input, field="input", span=self._otel_span - ) - media_processed_output = self._process_media_and_apply_mask( - data=output, field="output", span=self._otel_span - ) - - attributes = create_trace_attributes( - input=media_processed_input, - output=media_processed_output, - ) - - self._otel_span.set_attributes(attributes) - - return self - def set_trace_as_public(self) -> "LangfuseObservationWrapper": """Make this trace publicly accessible via its URL. diff --git a/langfuse/_client/span_exporter.py b/langfuse/_client/span_exporter.py index 661a1a26c..efbb04261 100644 --- a/langfuse/_client/span_exporter.py +++ b/langfuse/_client/span_exporter.py @@ -27,7 +27,6 @@ _INPUT_MEDIA_ATTRIBUTE_KEYS = frozenset( { - LangfuseOtelSpanAttributes.TRACE_INPUT, LangfuseOtelSpanAttributes.OBSERVATION_INPUT, "ai.prompt.messages", "ai.prompt", @@ -54,7 +53,6 @@ _OUTPUT_MEDIA_ATTRIBUTE_KEYS = frozenset( { - LangfuseOtelSpanAttributes.TRACE_OUTPUT, LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, "ai.response.text", "ai.result.text", diff --git a/langfuse/batch_evaluation.py b/langfuse/batch_evaluation.py index 723b45757..a761f67ce 100644 --- a/langfuse/batch_evaluation.py +++ b/langfuse/batch_evaluation.py @@ -1,9 +1,10 @@ """Batch evaluation functionality for Langfuse. This module provides comprehensive batch evaluation capabilities for running evaluations -on traces and observations fetched from Langfuse. It includes type definitions, -protocols, result classes, and the implementation for large-scale evaluation workflows -with error handling, retry logic, and resume capability. +on observations fetched from Langfuse via the v2 observations API +(`GET /api/public/v2/observations`). It includes type definitions, protocols, result +classes, and the implementation for large-scale evaluation workflows with error +handling, retry logic, and resume capability. """ import asyncio @@ -20,35 +21,36 @@ Set, Tuple, Union, - cast, ) -from langfuse.api import ( - ObservationsView, - TraceWithFullDetails, -) +from langfuse.api import ObservationV2 from langfuse.experiment import Evaluation, EvaluatorFunction from langfuse.logger import langfuse_logger as logger if TYPE_CHECKING: from langfuse._client.client import Langfuse +DEFAULT_BATCH_EVALUATION_FIELDS = "core,basic,io,metadata" +"""Default v2 observation field groups fetched for batch evaluation. + +Includes ``io`` (raw ``input``/``output`` strings) and ``metadata`` so that mappers +work without extra configuration. Available groups: core, basic, time, io, +metadata, model, usage, prompt, metrics, trace_context. +""" + class EvaluatorInputs: """Input data structure for evaluators, returned by mapper functions. - This class provides a strongly-typed container for transforming API response - objects (traces, observations) into the standardized format expected - by evaluator functions. It ensures consistent access to input, output, expected - output, and metadata regardless of the source entity type. + This class provides a strongly-typed container for transforming `ObservationV2` + objects returned by the v2 observations API into the standardized format + expected by evaluator functions. Attributes: - input: The input data that was provided to generate the output being evaluated. - For traces, this might be the initial prompt or request. For observations, - this could be the span's input. The exact meaning depends on your use case. - output: The actual output that was produced and needs to be evaluated. - For traces, this is typically the final response. For observations, - this might be the generation output or span result. + input: The input data that was provided to generate the output being evaluated, + for example the observation's input. + output: The actual output that was produced and needs to be evaluated, + for example the generation output or span result. expected_output: Optional ground truth or expected result for comparison. Used by evaluators to assess correctness. May be None if no ground truth is available for the entity being evaluated. @@ -57,38 +59,31 @@ class EvaluatorInputs: or any other relevant data that evaluators might use. Examples: - Simple mapper for traces: + Simple observation mapper: ```python from langfuse import EvaluatorInputs - def trace_mapper(trace): + def simple_mapper(*, item): return EvaluatorInputs( - input=trace.input, - output=trace.output, + input=item.input, # raw string as returned by the API + output=item.output, expected_output=None, # No ground truth available - metadata={"user_id": trace.user_id, "tags": trace.tags} + metadata={"trace_id": item.trace_id, "user_id": item.user_id}, ) ``` - Mapper for observations extracting specific fields: + Mapper that decodes JSON input/output: ```python - def observation_mapper(observation): - # Extract input/output from observation's data - input_data = observation.input if hasattr(observation, 'input') else None - output_data = observation.output if hasattr(observation, 'output') else None + import json + def observation_mapper(*, item): return EvaluatorInputs( - input=input_data, - output=output_data, + input=json.loads(item.input) if item.input else None, + output=json.loads(item.output) if item.output else None, expected_output=None, - metadata={ - "observation_type": observation.type, - "model": observation.model, - "latency_ms": observation.end_time - observation.start_time - } + metadata={"observation_type": item.type, "name": item.name}, ) ``` - ``` Note: All arguments must be passed as keywords when instantiating this class. @@ -122,34 +117,39 @@ def __init__( class MapperFunction(Protocol): """Protocol defining the interface for mapper functions in batch evaluation. - Mapper functions transform API response objects (traces or observations) - into the standardized EvaluatorInputs format that evaluators expect. This abstraction - allows you to define how to extract and structure evaluation data from different - entity types. + Mapper functions transform `ObservationV2` objects from the v2 observations API + into the standardized EvaluatorInputs format that evaluators expect. Mapper functions must: - - Accept a single item parameter (trace, observation) + - Accept a single keyword argument `item` (an `ObservationV2`) - Return an EvaluatorInputs instance with input, output, expected_output, metadata - Can be either synchronous or asynchronous - Should handle missing or malformed data gracefully + + Notes on `ObservationV2`: + - `input` and `output` are raw strings exactly as stored; the SDK does not + parse them. Use `json.loads` in the mapper if you need structured data. + - Only the requested field groups (see the `fields` argument of + `Langfuse.run_batched_evaluation`) are populated; fields of other groups + are None. + - `metadata` values longer than 200 characters are truncated by the API. + - Price fields (`input_price`, `output_price`, `total_price`) are strings. """ def __call__( self, *, - item: Union["TraceWithFullDetails", "ObservationsView"], + item: ObservationV2, **kwargs: Dict[str, Any], ) -> Union[EvaluatorInputs, Awaitable[EvaluatorInputs]]: - """Transform an API response object into evaluator inputs. + """Transform an observation into evaluator inputs. - This method defines how to extract evaluation-relevant data from the raw - API response object. The implementation should map entity-specific fields - to the standardized input/output/expected_output/metadata structure. + This method defines how to extract evaluation-relevant data from the + observation. The implementation should map its fields to the standardized + input/output/expected_output/metadata structure. Args: - item: The API response object to transform. The type depends on the scope: - - TraceWithFullDetails: When evaluating traces - - ObservationsView: When evaluating observations + item: The `ObservationV2` to transform. Returns: EvaluatorInputs: A structured container with: @@ -162,48 +162,45 @@ def __call__( (for async mappers that need to fetch additional data). Examples: - Basic trace mapper: + Basic observation mapper: ```python - def map_trace(trace): + def map_basic(*, item): return EvaluatorInputs( - input=trace.input, - output=trace.output, + input=item.input, + output=item.output, expected_output=None, - metadata={"trace_id": trace.id, "user": trace.user_id} + metadata={"trace_id": item.trace_id, "user": item.user_id} ) ``` Observation mapper with conditional logic: ```python - def map_observation(observation): - # Extract fields based on observation type - if observation.type == "GENERATION": - input_data = observation.input - output_data = observation.output + import json + + def map_observation(*, item): + if item.type == "GENERATION": + input_data = json.loads(item.input) if item.input else None else: - # For other types, use different fields - input_data = observation.metadata.get("input") - output_data = observation.metadata.get("output") + input_data = item.input return EvaluatorInputs( input=input_data, - output=output_data, + output=item.output, expected_output=None, - metadata={"obs_id": observation.id, "type": observation.type} + metadata={"obs_id": item.id, "type": item.type} ) ``` Async mapper (if additional processing needed): ```python - async def map_trace_async(trace): - # Could do async processing here if needed - processed_output = await some_async_transformation(trace.output) + async def map_async(*, item): + processed_output = await some_async_transformation(item.output) return EvaluatorInputs( - input=trace.input, + input=item.input, output=processed_output, expected_output=None, - metadata={"trace_id": trace.id} + metadata={"trace_id": item.trace_id} ) ``` """ @@ -452,122 +449,87 @@ def __init__( class BatchEvaluationResumeToken: - """Token for resuming a failed batch evaluation run. + """Token for resuming an interrupted or limited batch evaluation run. + + The v2 observations API returns observations ordered by start time, newest + first, and paginates with an opaque cursor. The token stores the cursor of + the next page that has not been processed yet, so a resumed run continues + exactly where the previous one stopped, without re-evaluating or skipping + items, even if new observations were ingested in the meantime. - This class encapsulates all the information needed to resume a batch evaluation - that was interrupted or failed partway through. It uses timestamp-based filtering - to avoid re-processing items that were already evaluated, even if the underlying - dataset changed between runs. + A token is returned when a run stops because a batch fetch failed after all + retries (`completed=False`) or because `max_items` was reached while more + items exist (`has_more_items=True`). Attributes: - scope: The type of items being evaluated ("traces", "observations"). - filter: The original JSON filter string used to query items. - last_processed_timestamp: ISO 8601 timestamp of the last successfully processed item. - Used to construct a filter that only fetches items after this timestamp. - last_processed_id: The ID of the last successfully processed item, for reference. - items_processed: Count of items successfully processed before interruption. + filter: The original JSON filter string used to query items. Pass the + same filter when resuming. + cursor: Cursor of the next page to fetch. None if no page was fetched yet. + last_processed_timestamp: ISO 8601 start time of the oldest processed + observation. Only used to resume when `cursor` is None, by fetching + observations that started strictly before this timestamp. + last_processed_id: The ID of the last processed observation, for reference. + items_processed: Number of items successfully processed so far, including + items processed by the runs this token was resumed from. Examples: - Resuming a failed batch evaluation: + Resuming a run that stopped early: ```python - # Initial run that fails partway through - try: - result = client.run_batched_evaluation( - scope="traces", - mapper=my_mapper, - evaluators=[evaluator1, evaluator2], - filter='{"tags": ["production"]}', - max_items=10000 - ) - except Exception as e: - print(f"Evaluation failed: {e}") - - # Save the resume token - if result.resume_token: - # Store resume token for later (e.g., in a file or database) - import json - with open("resume_token.json", "w") as f: - json.dump({ - "scope": result.resume_token.scope, - "filter": result.resume_token.filter, - "last_timestamp": result.resume_token.last_processed_timestamp, - "last_id": result.resume_token.last_processed_id, - "items_done": result.resume_token.items_processed - }, f) - - # Later, resume from where it left off - with open("resume_token.json") as f: - token_data = json.load(f) - - resume_token = BatchEvaluationResumeToken( - scope=token_data["scope"], - filter=token_data["filter"], - last_processed_timestamp=token_data["last_timestamp"], - last_processed_id=token_data["last_id"], - items_processed=token_data["items_done"] - ) - - # Resume the evaluation result = client.run_batched_evaluation( - scope="traces", mapper=my_mapper, evaluators=[evaluator1, evaluator2], - resume_from=resume_token + filter=my_filter, + max_items=10000, ) - print(f"Processed {result.total_items_processed} additional items") + if result.resume_token: + result = client.run_batched_evaluation( + mapper=my_mapper, + evaluators=[evaluator1, evaluator2], + filter=my_filter, + resume_from=result.resume_token, + ) ``` - Handling partial completion: + Persisting a token between processes: ```python - result = client.run_batched_evaluation(...) + import json - if not result.completed: - print(f"Evaluation incomplete. Processed {result.resume_token.items_processed} items") - print(f"Last item: {result.resume_token.last_processed_id}") - print(f"Resume from: {result.resume_token.last_processed_timestamp}") + token = result.resume_token + with open("resume_token.json", "w") as f: + json.dump(vars(token), f) - # Optionally retry automatically - if result.resume_token: - print("Retrying...") - result = client.run_batched_evaluation( - scope=result.resume_token.scope, - mapper=my_mapper, - evaluators=my_evaluators, - resume_from=result.resume_token - ) + with open("resume_token.json") as f: + token = BatchEvaluationResumeToken(**json.load(f)) ``` Note: All arguments must be passed as keywords when instantiating this class. - The timestamp-based approach means that items created after the initial run - but before the timestamp will be skipped. This is intentional to avoid - duplicates and ensure consistent evaluation. """ def __init__( self, *, - scope: str, filter: Optional[str], last_processed_timestamp: str, last_processed_id: str, items_processed: int, + cursor: Optional[str] = None, ): """Initialize BatchEvaluationResumeToken with the provided state. Args: - scope: The scope type ("traces", "observations"). filter: The original JSON filter string. - last_processed_timestamp: ISO 8601 timestamp of last processed item. + last_processed_timestamp: ISO 8601 start time of the oldest processed item. last_processed_id: ID of last processed item. items_processed: Count of items processed before interruption. + cursor: Cursor of the next page to fetch. Note: All arguments must be provided as keywords. """ - self.scope = scope self.filter = filter + self.cursor = cursor self.last_processed_timestamp = last_processed_timestamp self.last_processed_id = last_processed_id self.items_processed = items_processed @@ -588,13 +550,16 @@ class BatchEvaluationResult: total_composite_scores_created: Scores created by the composite evaluator. total_evaluations_failed: Number of individual evaluator failures across all items. evaluator_stats: List of per-evaluator statistics (success/failure rates, scores created). - resume_token: Token for resuming if evaluation was interrupted (None if completed). - completed: True if all items were processed, False if stopped early or failed. + resume_token: Token for continuing the run. Set when a batch fetch failed + (`completed=False`) or when `max_items` was reached while more items + exist (`has_more_items=True`); None otherwise. + completed: False if the run stopped because a batch fetch failed, True otherwise + (including when it stopped at `max_items`). duration_seconds: Total time taken to execute the batch evaluation. - failed_item_ids: List of IDs for items that failed evaluation. + failed_item_ids: List of observation IDs for items that failed evaluation. error_summary: Dictionary mapping error types to occurrence counts. has_more_items: True if max_items limit was reached but more items exist. - item_evaluations: Dictionary mapping item IDs to their evaluation results (both regular and composite). + item_evaluations: Dictionary mapping observation IDs to their evaluation results (both regular and composite). Examples: Basic result inspection: @@ -644,9 +609,7 @@ class BatchEvaluationResult: if result.resume_token: print(f"Processed {result.resume_token.items_processed} items before failure") - print(f"Use resume_from parameter to continue from:") - print(f" Timestamp: {result.resume_token.last_processed_timestamp}") - print(f" Last ID: {result.resume_token.last_processed_id}") + print("Pass it as resume_from to continue") if result.has_more_items: print(f"ℹ️ More items available beyond max_items limit") @@ -839,57 +802,65 @@ def __init__(self, client: "Langfuse"): async def run_async( self, *, - scope: str, mapper: MapperFunction, evaluators: List[EvaluatorFunction], filter: Optional[str] = None, fetch_batch_size: int = 50, - fetch_trace_fields: Optional[str] = "io", + fields: Optional[str] = DEFAULT_BATCH_EVALUATION_FIELDS, max_items: Optional[int] = None, max_concurrency: int = 5, composite_evaluator: Optional[CompositeEvaluatorFunction] = None, metadata: Optional[Dict[str, Any]] = None, _add_observation_scores_to_trace: bool = False, - _additional_trace_tags: Optional[List[str]] = None, max_retries: int = 3, verbose: bool = False, resume_from: Optional[BatchEvaluationResumeToken] = None, ) -> BatchEvaluationResult: - """Run batch evaluation asynchronously using legacy read APIs. + """Run batch evaluation asynchronously. This is the main implementation method that orchestrates the entire batch - evaluation process: fetching items, mapping, evaluating, creating scores, - and tracking statistics. - - This runner reads traces from `GET /api/public/traces` and observations - from the legacy `GET /api/public/observations` endpoint. It is supported - with Langfuse platform v3 and is not yet supported with platform v4. + evaluation process: fetching items from `GET /api/public/v2/observations` + with cursor pagination, mapping, evaluating, creating scores, and tracking + statistics. Args: - scope: The type of items to evaluate ("traces", "observations"). - mapper: Function to transform API response items to evaluator inputs. + mapper: Function to transform `ObservationV2` items to evaluator inputs. evaluators: List of evaluation functions to run on each item. - filter: JSON filter string for querying items. - fetch_batch_size: Number of items to fetch per API call. - fetch_trace_fields: Comma-separated list of fields to include when fetching traces. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. Only relevant if scope is 'traces'. Default: 'io' + filter: JSON filter string (v2 observations filter schema). + fetch_batch_size: Number of items to fetch per API call (max 1000). + fields: Comma-separated v2 observation field groups to fetch. max_items: Maximum number of items to process (None = all). max_concurrency: Maximum number of concurrent evaluations. composite_evaluator: Optional function to create composite scores. metadata: Metadata to add to all created scores. _add_observation_scores_to_trace: Private option to duplicate observation-level scores onto the parent trace. - _additional_trace_tags: Private option to add tags on traces via - ingestion trace-create events. max_retries: Maximum retries for failed batch fetches. verbose: If True, log progress to console. - resume_from: Resume token from a previous failed run. + resume_from: Resume token from a previous run. If `filter` is omitted, + the token's filter is reused. Returns: BatchEvaluationResult with comprehensive statistics. + + Raises: + ValueError: If the filter is not a JSON array, or the resume token was + created for a different filter. """ start_time = time.time() - # Initialize tracking variables + # The token's cursor only continues the query that produced it. + if resume_from is not None: + if filter is None: + filter = resume_from.filter + elif filter != resume_from.filter: + raise ValueError( + "Resume token was created for a different filter. Pass the " + "same filter, or omit it to reuse the token's filter." + ) + + effective_filter = self._build_filter(filter=filter, resume_from=resume_from) + total_items_fetched = 0 total_items_processed = 0 total_items_failed = 0 @@ -899,8 +870,8 @@ async def run_async( failed_item_ids: List[str] = [] error_summary: Dict[str, int] = {} item_evaluations: Dict[str, List[Evaluation]] = {} + previously_processed = resume_from.items_processed if resume_from else 0 - # Initialize evaluator stats evaluator_stats_dict = { getattr(evaluator, "__name__", "unknown_evaluator"): EvaluatorStats( name=getattr(evaluator, "__name__", "unknown_evaluator") @@ -908,65 +879,69 @@ async def run_async( for evaluator in evaluators } - # Handle resume token by modifying filter - effective_filter = self._build_timestamp_filter(filter, resume_from) - normalized_additional_trace_tags = ( - self._dedupe_tags(_additional_trace_tags) - if _additional_trace_tags is not None - else [] - ) - updated_trace_ids: Set[str] = set() - - # Create semaphore for concurrency control semaphore = asyncio.Semaphore(max_concurrency) - # Pagination state - page = 1 + cursor: Optional[str] = resume_from.cursor if resume_from else None has_more = True - last_item_timestamp: Optional[str] = None - last_item_id: Optional[str] = None + last_item_timestamp = ( + resume_from.last_processed_timestamp if resume_from else "" + ) + last_item_id = resume_from.last_processed_id if resume_from else "" + # The start-time fallback is inclusive so tied items are not lost; skip + # the one item the token says was already processed. + resumed_item_id = ( + resume_from.last_processed_id + if resume_from is not None + and resume_from.cursor is None + and resume_from.last_processed_timestamp + else None + ) + previous_page_ids: Set[Optional[str]] = set() + batch_number = 0 if verbose: - logger.info("Starting batch evaluation on %s", scope) - if scope == "traces" and fetch_trace_fields: - logger.info("Fetching trace fields: %s", fetch_trace_fields) + logger.info("Starting batch evaluation on observations") + if fields: + logger.info("Fetching observation fields: %s", fields) if resume_from: logger.info( - "Resuming from %s (%s items already processed)", - resume_from.last_processed_timestamp, + "Resuming after %s (%s items already processed)", + resume_from.last_processed_timestamp or "start", resume_from.items_processed, ) - # Main pagination loop - while has_more: - # Check if we've reached max_items + def build_resume_token() -> BatchEvaluationResumeToken: + return BatchEvaluationResumeToken( + filter=filter, + cursor=cursor, + last_processed_timestamp=last_item_timestamp, + last_processed_id=last_item_id, + items_processed=previously_processed + total_items_processed, + ) + + while True: if max_items is not None and total_items_fetched >= max_items: if verbose: logger.info("Reached max_items limit (%s)", max_items) - has_more = True # More items may exist break - # Fetch next batch with retry logic + # Clamping the page size keeps the cursor aligned with the last + # processed item, so resuming after max_items skips nothing. + limit = fetch_batch_size + if max_items is not None: + limit = min(limit, max_items - total_items_fetched) + try: - items = await self._fetch_batch_with_retry( - scope=scope, + items, next_cursor = await self._fetch_batch_with_retry( filter=effective_filter, - page=page, - limit=fetch_batch_size, + cursor=cursor, + limit=limit, max_retries=max_retries, - fields=fetch_trace_fields, + fields=fields, ) except Exception as e: - # Failed after max_retries - create resume token and return - error_msg = f"Failed to fetch batch after {max_retries} retries" - logger.error("%s: %s", error_msg, e) - - resume_token = BatchEvaluationResumeToken( - scope=scope, - filter=filter, # Original filter, not modified - last_processed_timestamp=last_item_timestamp or "", - last_processed_id=last_item_id or "", - items_processed=total_items_processed, + logger.error( + "Failed to fetch batch after %s retries: %s", max_retries, e ) return self._build_result( @@ -977,51 +952,28 @@ async def run_async( total_composite_scores_created=total_composite_scores_created, total_evaluations_failed=total_evaluations_failed, evaluator_stats_dict=evaluator_stats_dict, - resume_token=resume_token, + resume_token=build_resume_token(), completed=False, start_time=start_time, failed_item_ids=failed_item_ids, error_summary=error_summary, - has_more_items=has_more, + has_more_items=False, item_evaluations=item_evaluations, ) - # Check if we got any items - if not items: - has_more = False - if verbose: - logger.info("No more items to fetch") - break - + batch_number += 1 total_items_fetched += len(items) if verbose: - logger.info("Fetched batch %s (%s items)", page, len(items)) - - # Limit items if max_items would be exceeded - items_to_process = items - if max_items is not None: - remaining_capacity = max_items - total_items_processed - if len(items) > remaining_capacity: - items_to_process = items[:remaining_capacity] - if verbose: - logger.info( - "Limiting batch to %s items to respect max_items=%s", - len(items_to_process), - max_items, - ) + logger.info("Fetched batch %s (%s items)", batch_number, len(items)) - # Process items concurrently async def process_item( - item: Union[TraceWithFullDetails, ObservationsView], + item: ObservationV2, ) -> Tuple[str, Union[Tuple[int, int, int, List[Evaluation]], Exception]]: - """Process a single item and return (item_id, result).""" async with semaphore: - item_id = self._get_item_id(item, scope) try: result = await self._process_batch_evaluation_item( item=item, - scope=scope, mapper=mapper, evaluators=evaluators, composite_evaluator=composite_evaluator, @@ -1029,25 +981,27 @@ async def process_item( _add_observation_scores_to_trace=_add_observation_scores_to_trace, evaluator_stats_dict=evaluator_stats_dict, ) - return (item_id, result) + return (item.id, result) except Exception as e: - return (item_id, e) + return (item.id, e) - # Run all items in batch concurrently - tasks = [process_item(item) for item in items_to_process] - results = await asyncio.gather(*tasks) + items_to_process = self._deduplicate_page( + items, skip_ids=previous_page_ids | {resumed_item_id} + ) + previous_page_ids = {item.id for item in items} + + results = await asyncio.gather( + *[process_item(item) for item in items_to_process] + ) - # Process results and update statistics for item, (item_id, result) in zip(items_to_process, results): if isinstance(result, Exception): - # Item processing failed total_items_failed += 1 failed_item_ids.append(item_id) error_type = type(result).__name__ error_summary[error_type] = error_summary.get(error_type, 0) + 1 logger.warning("Item %s failed: %s", item_id, result) else: - # Item processed successfully total_items_processed += 1 scores_created, composite_created, evals_failed, evaluations = ( result @@ -1055,36 +1009,19 @@ async def process_item( total_scores_created += scores_created total_composite_scores_created += composite_created total_evaluations_failed += evals_failed - - # Store evaluations for this item item_evaluations[item_id] = evaluations - if normalized_additional_trace_tags: - trace_id = ( - item_id - if scope == "traces" - else cast(ObservationsView, item).trace_id - ) - - if trace_id and trace_id not in updated_trace_ids: - self.client._create_trace_tags_via_ingestion( - trace_id=trace_id, - tags=normalized_additional_trace_tags, - ) - updated_trace_ids.add(trace_id) - - # Update last processed tracking - last_item_timestamp = self._get_item_timestamp(item, scope) - last_item_id = item_id + if items: + last_item_timestamp = items[-1].start_time.isoformat() + last_item_id = items[-1].id if verbose: if max_items is not None and max_items > 0: - progress_pct = total_items_processed / max_items * 100 logger.info( "Progress: %s/%s items (%.1f%%), %s scores created", total_items_processed, max_items, - progress_pct, + total_items_processed / max_items * 100, total_scores_created, ) else: @@ -1094,40 +1031,22 @@ async def process_item( total_scores_created, ) - # Check if we should continue to next page - if len(items) < fetch_batch_size: - # Last page - no more items available + cursor = next_cursor + if cursor is None or not items: has_more = False - else: - page += 1 - - # Check max_items again before next fetch - if max_items is not None and total_items_fetched >= max_items: - has_more = True # More items exist but we're stopping - break + break - # Flush all scores to Langfuse if verbose: logger.info("Flushing scores to Langfuse...") self.client.flush() - # Build final result - duration = time.time() - start_time - if verbose: logger.info( "Batch evaluation complete: %s items processed in %.2fs", total_items_processed, - duration, + time.time() - start_time, ) - # Completed successfully if we either: - # 1. Ran out of items (has_more is False), OR - # 2. Hit max_items limit (intentionally stopped) - completed_successfully = not has_more or ( - max_items is not None and total_items_fetched >= max_items - ) - return self._build_result( total_items_fetched=total_items_fetched, total_items_processed=total_items_processed, @@ -1136,69 +1055,54 @@ async def process_item( total_composite_scores_created=total_composite_scores_created, total_evaluations_failed=total_evaluations_failed, evaluator_stats_dict=evaluator_stats_dict, - resume_token=None, # No resume needed on successful completion - completed=completed_successfully, + resume_token=build_resume_token() if has_more else None, + completed=True, start_time=start_time, failed_item_ids=failed_item_ids, error_summary=error_summary, - has_more_items=( - has_more and max_items is not None and total_items_fetched >= max_items - ), + has_more_items=has_more, item_evaluations=item_evaluations, ) async def _fetch_batch_with_retry( self, *, - scope: str, filter: Optional[str], - page: int, + cursor: Optional[str], limit: int, max_retries: int, fields: Optional[str], - ) -> List[Union[TraceWithFullDetails, ObservationsView]]: - """Fetch a batch of items with retry logic. + ) -> Tuple[List[ObservationV2], Optional[str]]: + """Fetch one page of observations from the v2 observations API. Args: - scope: The type of items ("traces", "observations"). filter: JSON filter string for querying. - page: Page number (1-indexed). - limit: Number of items per page. + cursor: Cursor returned with the previous page; None for the first page. + limit: Number of items to request. max_retries: Maximum number of retry attempts. - verbose: Whether to log retry attempts. - fields: Trace fields to fetch + fields: Comma-separated v2 field groups to include. Returns: - List of items from the API. + Tuple of the page's observations and the cursor for the next page + (None when there are no more pages). Raises: Exception: If all retry attempts fail. """ - if scope == "traces": - response = self.client.api.trace.list( - page=page, - limit=limit, - filter=filter, - request_options={"max_retries": max_retries}, - fields=fields, - ) # type: ignore - return list(response.data) # type: ignore - elif scope == "observations": - response = self.client.api.legacy.observations_v1.get_many( - page=page, - limit=limit, - filter=filter, - request_options={"max_retries": max_retries}, - ) # type: ignore - return list(response.data) # type: ignore - else: - error_message = f"Invalid scope: {scope}" - raise ValueError(error_message) + response = await asyncio.to_thread( + self.client.api.observations.get_many, + fields=fields, + limit=limit, + cursor=cursor, + filter=filter, + request_options={"max_retries": max_retries}, + ) + + return list(response.data), response.meta.cursor async def _process_batch_evaluation_item( self, - item: Union[TraceWithFullDetails, ObservationsView], - scope: str, + item: ObservationV2, mapper: MapperFunction, evaluators: List[EvaluatorFunction], composite_evaluator: Optional[CompositeEvaluatorFunction], @@ -1209,8 +1113,7 @@ async def _process_batch_evaluation_item( """Process a single item: map, evaluate, create scores. Args: - item: The API response object to evaluate. - scope: The type of item ("traces", "observations"). + item: The observation to evaluate. mapper: Function to transform item to evaluator inputs. evaluators: List of evaluator functions. composite_evaluator: Optional composite evaluator function. @@ -1225,14 +1128,16 @@ async def _process_batch_evaluation_item( Raises: Exception: If mapping fails or item processing encounters fatal error. """ + if not item.trace_id: + message = f"Observation {item.id} has no trace_id" + raise ValueError(message) + scores_created = 0 composite_scores_created = 0 evaluations_failed = 0 - # Run mapper to transform item evaluator_inputs = await self._run_mapper(mapper, item) - # Run all evaluators evaluations: List[Evaluation] = [] for evaluator in evaluators: evaluator_name = getattr(evaluator, "__name__", "unknown_evaluator") @@ -1253,31 +1158,23 @@ async def _process_batch_evaluation_item( evaluations.extend(eval_results) except Exception as e: - # Evaluator failed - log warning and continue with other evaluators stats.failed_runs += 1 evaluations_failed += 1 logger.warning( "Evaluator %s failed on item %s: %s", evaluator_name, - self._get_item_id(item, scope), + item.id, e, ) - # Create scores for item-level evaluations - item_id = self._get_item_id(item, scope) for evaluation in evaluations: - scores_created += self._create_score_for_scope( - scope=scope, - item_id=item_id, - trace_id=cast(ObservationsView, item).trace_id - if scope == "observations" - else None, + scores_created += self._create_score( + item=item, evaluation=evaluation, additional_metadata=metadata, add_observation_score_to_trace=_add_observation_scores_to_trace, ) - # Run composite evaluator if provided and we have evaluations if composite_evaluator and evaluations: try: composite_evals = await self._run_composite_evaluator( @@ -1289,24 +1186,18 @@ async def _process_batch_evaluation_item( evaluations=evaluations, ) - # Create scores for all composite evaluations for composite_eval in composite_evals: - composite_scores_created += self._create_score_for_scope( - scope=scope, - item_id=item_id, - trace_id=cast(ObservationsView, item).trace_id - if scope == "observations" - else None, + composite_scores_created += self._create_score( + item=item, evaluation=composite_eval, additional_metadata=metadata, add_observation_score_to_trace=_add_observation_scores_to_trace, ) - # Add composite evaluations to the list evaluations.extend(composite_evals) except Exception as e: - logger.warning("Composite evaluator failed on item %s: %s", item_id, e) + logger.warning("Composite evaluator failed on item %s: %s", item.id, e) return ( scores_created, @@ -1337,11 +1228,9 @@ async def _run_evaluator_internal( """ result = evaluator(**kwargs) - # Handle async evaluators if asyncio.iscoroutine(result): result = await result - # Normalize to list if isinstance(result, (dict, Evaluation)): return [result] # type: ignore elif isinstance(result, list): @@ -1352,13 +1241,13 @@ async def _run_evaluator_internal( async def _run_mapper( self, mapper: MapperFunction, - item: Union[TraceWithFullDetails, ObservationsView], + item: ObservationV2, ) -> EvaluatorInputs: """Run mapper function (handles both sync and async mappers). Args: mapper: The mapper function to run. - item: The API response object to map. + item: The observation to map. Returns: EvaluatorInputs instance. @@ -1406,7 +1295,6 @@ async def _run_composite_evaluator( if asyncio.iscoroutine(result): result = await result - # Normalize to list (same as regular evaluator) if isinstance(result, (dict, Evaluation)): return [result] # type: ignore elif isinstance(result, list): @@ -1414,22 +1302,18 @@ async def _run_composite_evaluator( else: return [] - def _create_score_for_scope( + def _create_score( self, *, - scope: str, - item_id: str, - trace_id: Optional[str] = None, + item: ObservationV2, evaluation: Evaluation, additional_metadata: Optional[Dict[str, Any]], add_observation_score_to_trace: bool = False, ) -> int: - """Create a score linked to the appropriate entity based on scope. + """Create a score on the evaluated observation, optionally duplicated onto its trace. Args: - scope: The type of entity ("traces", "observations"). - item_id: The ID of the entity. - trace_id: The trace ID of the entity; required if scope=observations + item: The evaluated observation. evaluation: The evaluation result to create a score from. additional_metadata: Additional metadata to merge with evaluation metadata. add_observation_score_to_trace: Whether to duplicate observation @@ -1438,165 +1322,104 @@ def _create_score_for_scope( Returns: Number of score events created. """ - # Merge metadata score_metadata = { **(evaluation.metadata or {}), **(additional_metadata or {}), } + score_kwargs: Dict[str, Any] = { + "name": evaluation.name, + "value": evaluation.value, + "comment": evaluation.comment, + "metadata": score_metadata, + "data_type": evaluation.data_type, + "config_id": evaluation.config_id, + } - if scope == "traces": - self.client.create_score( - trace_id=item_id, - name=evaluation.name, - value=evaluation.value, # type: ignore - comment=evaluation.comment, - metadata=score_metadata, - data_type=evaluation.data_type, # type: ignore[arg-type] - config_id=evaluation.config_id, - ) + self.client.create_score( + observation_id=item.id, trace_id=item.trace_id, **score_kwargs + ) + if not add_observation_score_to_trace: return 1 - elif scope == "observations": - self.client.create_score( - observation_id=item_id, - trace_id=trace_id, - name=evaluation.name, - value=evaluation.value, # type: ignore - comment=evaluation.comment, - metadata=score_metadata, - data_type=evaluation.data_type, # type: ignore[arg-type] - config_id=evaluation.config_id, - ) - score_count = 1 - - if add_observation_score_to_trace and trace_id: - self.client.create_score( - trace_id=trace_id, - name=evaluation.name, - value=evaluation.value, # type: ignore - comment=evaluation.comment, - metadata=score_metadata, - data_type=evaluation.data_type, # type: ignore[arg-type] - config_id=evaluation.config_id, - ) - score_count += 1 - return score_count + self.client.create_score(trace_id=item.trace_id, **score_kwargs) + return 2 - return 0 - - def _build_timestamp_filter( - self, - original_filter: Optional[str], + @staticmethod + def _build_filter( + *, + filter: Optional[str], resume_from: Optional[BatchEvaluationResumeToken], ) -> Optional[str]: - """Build filter with timestamp constraint for resume capability. + """Combine the user filter with the resume constraint. + + Constraints are added as JSON filter conditions rather than query + parameters because the API drops a query-parameter filter whenever the + JSON filter has a condition on the same column. Args: - original_filter: The original JSON filter string. - resume_from: Optional resume token with timestamp information. + filter: The user-provided JSON filter string (a JSON array). + resume_from: Optional resume token. Returns: - Modified filter string with timestamp constraint, or original filter. - """ - if not resume_from: - return original_filter + The JSON filter string to send, or None if there are no conditions. - # Parse original filter (should be array) or create empty array - try: - filter_list = json.loads(original_filter) if original_filter else [] - if not isinstance(filter_list, list): - logger.warning( - "Filter should be a JSON array, got: %s", type(filter_list).__name__ + Raises: + ValueError: If the filter is not a JSON array. + """ + conditions: List[Any] = [] + if filter: + try: + parsed = json.loads(filter) + except json.JSONDecodeError as e: + message = f"filter must be a JSON array: {e}" + raise ValueError(message) from e + if not isinstance(parsed, list): + message = ( + "filter must be a JSON array of conditions, " + f"got {type(parsed).__name__}" ) - filter_list = [] - except json.JSONDecodeError: - logger.warning( - "Invalid JSON in original filter, ignoring: %s", original_filter + raise ValueError(message) + conditions.extend(parsed) + + # Results are ordered by start time descending, so items that remain + # after the last processed one started before it. The cursor resumes + # exactly; the timestamp is the fallback for tokens without a cursor. + if ( + resume_from is not None + and resume_from.cursor is None + and resume_from.last_processed_timestamp + ): + conditions.append( + { + "type": "datetime", + "column": "startTime", + "operator": "<=", + "value": resume_from.last_processed_timestamp, + } ) - filter_list = [] - - # Add timestamp constraint to filter array - timestamp_field = self._get_timestamp_field_for_scope(resume_from.scope) - timestamp_filter = { - "type": "datetime", - "column": timestamp_field, - "operator": ">", - "value": resume_from.last_processed_timestamp, - } - filter_list.append(timestamp_filter) - - return json.dumps(filter_list) - - @staticmethod - def _get_item_id( - item: Union[TraceWithFullDetails, ObservationsView], - scope: str, - ) -> str: - """Extract ID from item based on scope. - Args: - item: The API response object. - scope: The type of item. - - Returns: - The item's ID. - """ - return item.id + return json.dumps(conditions) if conditions else None @staticmethod - def _get_item_timestamp( - item: Union[TraceWithFullDetails, ObservationsView], - scope: str, - ) -> str: - """Extract timestamp from item based on scope. + def _deduplicate_page( + items: List[ObservationV2], *, skip_ids: Set[Optional[str]] + ) -> List[ObservationV2]: + """Keep one row per observation id, preferring the most recently updated. - Args: - item: The API response object. - scope: The type of item. - - Returns: - ISO 8601 timestamp string. + Until ClickHouse merges them, the events table can return several rows + for one observation, within a page or across a page boundary. """ - if scope == "traces": - # Type narrowing for traces - if hasattr(item, "timestamp"): - return item.timestamp.isoformat() # type: ignore[attr-defined] - elif scope == "observations": - # Type narrowing for observations - if hasattr(item, "start_time"): - return item.start_time.isoformat() # type: ignore[attr-defined] - return "" - - @staticmethod - def _get_timestamp_field_for_scope(scope: str) -> str: - """Get the timestamp field name for filtering based on scope. - - Args: - scope: The type of items. - - Returns: - The field name to use in filters. - """ - if scope == "traces": - return "timestamp" - elif scope == "observations": - return "start_time" - return "timestamp" # Default - - @staticmethod - def _dedupe_tags(tags: Optional[List[str]]) -> List[str]: - """Deduplicate tags while preserving order.""" - if tags is None: - return [] - - deduped: List[str] = [] - seen = set() - for tag in tags: - if tag not in seen: - deduped.append(tag) - seen.add(tag) - - return deduped + latest_by_id: Dict[str, ObservationV2] = {} + for item in items: + if item.id in skip_ids: + continue + current = latest_by_id.get(item.id) + if current is None or (item.updated_at or item.start_time) > ( + current.updated_at or current.start_time + ): + latest_by_id[item.id] = item + + return list(latest_by_id.values()) def _build_result( self, @@ -1625,12 +1448,12 @@ def _build_result( total_composite_scores_created: Scores from composite evaluator. total_evaluations_failed: Individual evaluator failures. evaluator_stats_dict: Per-evaluator statistics. - resume_token: Resume token if incomplete. - completed: Whether evaluation completed fully. + resume_token: Resume token if the run can be continued. + completed: Whether evaluation completed without a fetch failure. start_time: Start time (unix timestamp). failed_item_ids: IDs of failed items. error_summary: Error type counts. - has_more_items: Whether more items exist. + has_more_items: Whether more items exist beyond max_items. item_evaluations: Dictionary mapping item IDs to their evaluation results. Returns: diff --git a/tests/e2e/test_batch_evaluation.py b/tests/e2e/test_batch_evaluation.py index 0632b21b8..edbea8ebc 100644 --- a/tests/e2e/test_batch_evaluation.py +++ b/tests/e2e/test_batch_evaluation.py @@ -1,1139 +1,268 @@ -"""Comprehensive tests for batch evaluation functionality. +"""End-to-end tests for run_batched_evaluation against a Langfuse server. -This test suite covers the run_batched_evaluation method which allows evaluating -traces, observations, and sessions fetched from Langfuse with mappers, evaluators, -and composite evaluators. +Every run is restricted to a corpus seeded by this module (filtered by a unique +tag), so assertions do not depend on other data in the project. Runner logic +that does not need a server is covered in tests/unit/test_batch_evaluation.py. """ -import asyncio -import time +import json +from dataclasses import dataclass +from typing import Any, List import pytest from langfuse import get_client, propagate_attributes -from langfuse.batch_evaluation import ( - BatchEvaluationResult, - BatchEvaluationResumeToken, - EvaluatorInputs, - EvaluatorStats, -) +from langfuse.api import ObservationV2 +from langfuse.batch_evaluation import EvaluatorInputs from langfuse.experiment import Evaluation from tests.support.utils import create_uuid, get_api, wait_for_result -# ============================================================================ -# FIXTURES & SETUP -# ============================================================================ +TRACE_COUNT = 4 -# pytestmark = pytest.mark.skip(reason="Github CI runner overwhelmed by score volume") +@dataclass +class Corpus: + tag: str + filter: str + trace_ids: List[str] + root_ids: List[str] + child_ids: List[str] -@pytest.fixture -def langfuse_client(): - """Get a Langfuse client for testing.""" - return get_client() - - -@pytest.fixture -def sample_trace_name(): - """Generate a unique trace name for filtering.""" - return f"batch-eval-test-{create_uuid()}" - - -def _seed_trace_corpus( - *, trace_count: int = 6, tag: str | None = None -) -> tuple[str, list[str]]: - langfuse_client = get_client() - corpus_tag = tag or f"batch-eval-seed-{create_uuid()}" - trace_names: list[str] = [] - - for index in range(trace_count): - trace_name = f"{corpus_tag}-trace-{index}" - trace_names.append(trace_name) - with langfuse_client.start_as_current_observation(name=trace_name) as span: - with propagate_attributes(tags=[corpus_tag]): - span.set_trace_io( - input=f"Seed input {index}", - output=f"Seed output {index}", - ) +def _tag_filter(tag: str) -> str: + return json.dumps( + [ + { + "type": "arrayOptions", + "column": "tags", + "operator": "any of", + "value": [tag], + } + ] + ) - langfuse_client.flush() - filter_json = f'[{{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["{corpus_tag}"]}}]' +def _wait_for_observations(filter_json: str, expected_count: int) -> List[Any]: api = get_api(retry=False) - wait_for_result( - lambda: api.trace.list(filter=filter_json, limit=trace_count), - is_result_ready=lambda response: len(response.data) >= trace_count, + response = wait_for_result( + lambda: api.observations.get_many(filter=filter_json, limit=100), + is_result_ready=lambda r: len(r.data) >= expected_count, ) - - return corpus_tag, trace_names - - -@pytest.fixture(scope="module", autouse=True) -def seeded_batch_evaluation_traces(): - _seed_trace_corpus() + return list(response.data) -def simple_trace_mapper(*, item): - """Simple mapper for traces.""" +def _wait_for_scores(**kwargs: Any) -> List[Any]: + api = get_api(retry=False) + response = wait_for_result( + lambda: api.scores_v3.get_many_v3(fields="details,subject", **kwargs), + is_result_ready=lambda r: len(r.data) > 0, + ) + return list(response.data) + + +@pytest.fixture(scope="module") +def corpus() -> Corpus: + langfuse = get_client() + tag = f"batch-eval-{create_uuid()}" + trace_ids, root_ids, child_ids = [], [], [] + + for index in range(TRACE_COUNT): + with langfuse.start_as_current_observation(name=f"{tag}-root") as root: + with propagate_attributes(tags=[tag]): + with langfuse.start_as_current_observation( + as_type="generation", + name=f"{tag}-child", + input={"question": index}, + output=f"child answer {index}", + ) as child: + child_ids.append(child.id) + root.update(input={"question": index}, output=f"answer {index}") + trace_ids.append(root.trace_id) + root_ids.append(root.id) + + langfuse.flush() + _wait_for_observations(_tag_filter(tag), 2 * TRACE_COUNT) + + return Corpus( + tag=tag, + filter=_tag_filter(tag), + trace_ids=trace_ids, + root_ids=root_ids, + child_ids=child_ids, + ) + + +def io_mapper(*, item: ObservationV2) -> EvaluatorInputs: return EvaluatorInputs( - input=item.input if hasattr(item, "input") else None, - output=item.output if hasattr(item, "output") else None, - expected_output=None, - metadata={"trace_id": item.id}, + input=item.input, + output=item.output, + metadata={"trace_id": item.trace_id}, ) -def simple_evaluator(*, input, output, expected_output=None, metadata=None, **kwargs): - """Simple evaluator that returns a score based on output length.""" - if output is None: - return Evaluation(name="length_score", value=0.0, comment="No output") - - return Evaluation( - name="length_score", - value=float(len(str(output))) / 10.0, - comment=f"Length: {len(str(output))}", - ) +def length_evaluator(*, output, **kwargs): + return Evaluation(name="length", value=float(len(output or ""))) -# ============================================================================ -# BASIC FUNCTIONALITY TESTS -# ============================================================================ +def test_evaluates_every_observation(corpus): + seen: List[ObservationV2] = [] + def recording_mapper(*, item): + seen.append(item) + return io_mapper(item=item) -def test_run_batched_evaluation_on_observations_basic(langfuse_client): - """Test basic batch evaluation on traces.""" - result = langfuse_client.run_batched_evaluation( - scope="observations", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=1, - verbose=True, + result = get_client().run_batched_evaluation( + mapper=recording_mapper, + evaluators=[length_evaluator], + filter=corpus.filter, ) - # Validate result structure - assert isinstance(result, BatchEvaluationResult) - assert result.total_items_fetched >= 0 - assert result.total_items_processed >= 0 - assert result.total_scores_created >= 0 assert result.completed is True - assert isinstance(result.duration_seconds, float) - assert result.duration_seconds > 0 - - # Verify evaluator stats - assert len(result.evaluator_stats) == 1 - stats = result.evaluator_stats[0] - assert isinstance(stats, EvaluatorStats) - assert stats.name == "simple_evaluator" - - -def test_run_batched_evaluation_on_traces_basic(langfuse_client): - """Test basic batch evaluation on traces.""" - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=5, - verbose=True, - ) - - # Validate result structure - assert isinstance(result, BatchEvaluationResult) - assert result.total_items_fetched >= 0 - assert result.total_items_processed >= 0 - assert result.total_scores_created >= 0 - assert result.completed is True - assert isinstance(result.duration_seconds, float) - assert result.duration_seconds > 0 - - # Verify evaluator stats - assert len(result.evaluator_stats) == 1 - stats = result.evaluator_stats[0] - assert isinstance(stats, EvaluatorStats) - assert stats.name == "simple_evaluator" - - -def test_batch_evaluation_with_filter(langfuse_client): - """Test batch evaluation with JSON filter.""" - # Create a trace with specific tag - unique_tag = f"test-filter-{create_uuid()}" - with langfuse_client.start_as_current_observation( - name=f"filtered-trace-{create_uuid()}" - ) as span: - with propagate_attributes(tags=[unique_tag]): - span.set_trace_io( - input="Filtered test", - output="Filtered output", - ) - - langfuse_client.flush() - time.sleep(3) - - # Filter format: array of filter conditions - filter_json = f'[{{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["{unique_tag}"]}}]' - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - filter=filter_json, - verbose=True, - ) - - # Should only process the filtered trace - assert result.total_items_fetched >= 1 - assert result.completed is True - - -def test_batch_evaluation_with_metadata(langfuse_client): - """Test that additional metadata is added to all scores.""" - - def metadata_checking_evaluator(*, input, output, metadata=None, **kwargs): - return Evaluation( - name="test_score", - value=1.0, - metadata={"evaluator_data": "test"}, - ) - - additional_metadata = { - "batch_run_id": "test-batch-123", - "evaluation_version": "v2.0", + assert result.has_more_items is False + assert result.resume_token is None + assert result.total_items_fetched == 2 * TRACE_COUNT + assert result.total_items_processed == 2 * TRACE_COUNT + assert result.total_scores_created == 2 * TRACE_COUNT + assert set(result.item_evaluations) == set(corpus.root_ids + corpus.child_ids) + + by_id = {item.id: item for item in seen} + child = by_id[corpus.child_ids[0]] + assert isinstance(child.input, str) + assert json.loads(child.input) == {"question": 0} + assert child.output == "child answer 0" + + +def test_observation_scores_are_attached_to_observations(corpus): + score_name = f"obs-score-{create_uuid()}" + child_filter = json.loads(corpus.filter) + [ + { + "type": "string", + "column": "id", + "operator": "=", + "value": corpus.child_ids[1], + } + ] + + def observation_evaluator(**kwargs): + return Evaluation(name=score_name, value=0.5, comment="ok") + + result = get_client().run_batched_evaluation( + mapper=io_mapper, + evaluators=[observation_evaluator], + filter=json.dumps(child_filter), + metadata={"run": "e2e"}, + ) + + assert result.total_items_processed == 1 + + scores = _wait_for_scores( + trace_id=corpus.trace_ids[1], + observation_id=corpus.child_ids[1], + name=score_name, + ) + assert len(scores) == 1 + assert scores[0].subject.kind == "observation" + assert scores[0].subject.id == corpus.child_ids[1] + assert scores[0].subject.trace_id == corpus.trace_ids[1] + assert scores[0].comment == "ok" + assert scores[0].metadata == {"run": "e2e"} + + +def test_max_items_then_resume_covers_corpus_exactly_once(corpus): + langfuse = get_client() + run_kwargs: Any = { + "mapper": io_mapper, + "evaluators": [length_evaluator], + "filter": corpus.filter, + "fetch_batch_size": 2, } - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[metadata_checking_evaluator], - metadata=additional_metadata, - max_items=2, - ) - - assert result.total_scores_created > 0 - - # Verify scores were created with merged metadata - langfuse_client.flush() - time.sleep(3) - - # Note: In a real test, you'd verify via API that metadata was merged - # For now, just verify the operation completed - assert result.completed is True + first = langfuse.run_batched_evaluation(max_items=3, **run_kwargs) + assert first.total_items_fetched == 3 + assert first.has_more_items is True + assert first.resume_token is not None + assert first.resume_token.cursor is not None -def test_result_structure_fields(langfuse_client): - """Test that BatchEvaluationResult has all expected fields.""" - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=3, + second = langfuse.run_batched_evaluation( + resume_from=first.resume_token, **run_kwargs ) - # Check all result fields exist - assert hasattr(result, "total_items_fetched") - assert hasattr(result, "total_items_processed") - assert hasattr(result, "total_items_failed") - assert hasattr(result, "total_scores_created") - assert hasattr(result, "total_composite_scores_created") - assert hasattr(result, "total_evaluations_failed") - assert hasattr(result, "evaluator_stats") - assert hasattr(result, "resume_token") - assert hasattr(result, "completed") - assert hasattr(result, "duration_seconds") - assert hasattr(result, "failed_item_ids") - assert hasattr(result, "error_summary") - assert hasattr(result, "has_more_items") - assert hasattr(result, "item_evaluations") - - # Check types - assert isinstance(result.evaluator_stats, list) - assert isinstance(result.failed_item_ids, list) - assert isinstance(result.error_summary, dict) - assert isinstance(result.completed, bool) - assert isinstance(result.has_more_items, bool) - assert isinstance(result.item_evaluations, dict) - - -# ============================================================================ -# MAPPER FUNCTION TESTS -# ============================================================================ - - -def test_simple_mapper(langfuse_client): - """Test basic mapper functionality.""" - - def custom_mapper(*, item): - return EvaluatorInputs( - input=item.input if hasattr(item, "input") else "no input", - output=item.output if hasattr(item, "output") else "no output", - expected_output=None, - metadata={"custom_field": "test_value"}, - ) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=custom_mapper, - evaluators=[simple_evaluator], - max_items=2, + assert second.completed is True + assert second.resume_token is None + assert set(first.item_evaluations).isdisjoint(second.item_evaluations) + assert set(first.item_evaluations) | set(second.item_evaluations) == set( + corpus.root_ids + corpus.child_ids ) - assert result.total_items_processed > 0 - - -@pytest.mark.asyncio -async def test_async_mapper(langfuse_client): - """Test that async mappers work correctly.""" - - async def async_mapper(*, item): - await asyncio.sleep(0.01) # Simulate async work - return EvaluatorInputs( - input=item.input if hasattr(item, "input") else None, - output=item.output if hasattr(item, "output") else None, - expected_output=None, - metadata={"async": True}, - ) - - # Note: run_batched_evaluation is synchronous but handles async mappers - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=async_mapper, - evaluators=[simple_evaluator], - max_items=2, - ) - - assert result.total_items_processed > 0 - - -def test_mapper_failure_handling(langfuse_client): - """Test that mapper failures cause items to be skipped.""" - - def failing_mapper(*, item): - raise ValueError("Intentional mapper failure") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=failing_mapper, - evaluators=[simple_evaluator], - max_items=3, - ) - - # All items should fail due to mapper failures - assert result.total_items_failed > 0 - assert len(result.failed_item_ids) > 0 - assert "ValueError" in result.error_summary or "Exception" in result.error_summary - - -def test_mapper_with_missing_fields(langfuse_client): - """Test mapper handles traces with missing fields gracefully.""" - - def robust_mapper(*, item): - # Handle missing fields with defaults - input_val = getattr(item, "input", None) or "default_input" - output_val = getattr(item, "output", None) or "default_output" - - return EvaluatorInputs( - input=input_val, - output=output_val, - expected_output=None, - metadata={}, - ) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=robust_mapper, - evaluators=[simple_evaluator], - max_items=2, - ) - - assert result.total_items_processed > 0 - - -# ============================================================================ -# EVALUATOR TESTS -# ============================================================================ - - -def test_single_evaluator(langfuse_client): - """Test with a single evaluator.""" - - def quality_evaluator(*, input, output, **kwargs): - return Evaluation(name="quality", value=0.85, comment="High quality") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[quality_evaluator], - max_items=2, - ) - assert result.total_scores_created > 0 - assert len(result.evaluator_stats) == 1 - assert result.evaluator_stats[0].name == "quality_evaluator" +def test_fields_control_populated_field_groups(corpus): + seen: List[ObservationV2] = [] + def recording_mapper(*, item): + seen.append(item) + return EvaluatorInputs(input=None, output=None) -def test_multiple_evaluators(langfuse_client): - """Test with multiple evaluators running in parallel.""" - - def accuracy_evaluator(*, input, output, **kwargs): - return Evaluation(name="accuracy", value=0.9) - - def relevance_evaluator(*, input, output, **kwargs): - return Evaluation(name="relevance", value=0.8) - - def safety_evaluator(*, input, output, **kwargs): - return Evaluation(name="safety", value=1.0) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[accuracy_evaluator, relevance_evaluator, safety_evaluator], - max_items=2, - ) - - # Should have 3 evaluators - assert len(result.evaluator_stats) == 3 - assert result.total_scores_created >= result.total_items_processed * 3 - - -@pytest.mark.asyncio -async def test_async_evaluator(langfuse_client): - """Test that async evaluators work correctly.""" - - async def async_evaluator(*, input, output, **kwargs): - await asyncio.sleep(0.01) # Simulate async work - return Evaluation(name="async_score", value=0.75) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[async_evaluator], - max_items=2, - ) - - assert result.total_scores_created > 0 - - -def test_evaluator_returning_list(langfuse_client): - """Test evaluator that returns multiple Evaluations.""" - - def multi_score_evaluator(*, input, output, **kwargs): - return [ - Evaluation(name="score_1", value=0.8), - Evaluation(name="score_2", value=0.9), - Evaluation(name="score_3", value=0.7), - ] - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[multi_score_evaluator], - max_items=2, - ) - - # Should create 3 scores per item - assert result.total_scores_created >= result.total_items_processed * 3 - - -def test_evaluator_failure_statistics(langfuse_client): - """Test that evaluator failures are tracked in statistics.""" - - def working_evaluator(*, input, output, **kwargs): - return Evaluation(name="working", value=1.0) - - def failing_evaluator(*, input, output, **kwargs): - raise RuntimeError("Intentional evaluator failure") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[working_evaluator, failing_evaluator], - max_items=3, - ) - - # Verify evaluator stats - assert len(result.evaluator_stats) == 2 - - working_stats = next( - s for s in result.evaluator_stats if s.name == "working_evaluator" - ) - assert working_stats.successful_runs > 0 - assert working_stats.failed_runs == 0 - - failing_stats = next( - s for s in result.evaluator_stats if s.name == "failing_evaluator" - ) - assert failing_stats.failed_runs > 0 - assert failing_stats.successful_runs == 0 - - # Total evaluations failed should be tracked - assert result.total_evaluations_failed > 0 - - -def test_mixed_sync_async_evaluators(langfuse_client): - """Test mixing synchronous and asynchronous evaluators.""" - - def sync_evaluator(*, input, output, **kwargs): - return Evaluation(name="sync_score", value=0.8) - - async def async_evaluator(*, input, output, **kwargs): - await asyncio.sleep(0.01) - return Evaluation(name="async_score", value=0.9) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[sync_evaluator, async_evaluator], - max_items=2, - ) - - assert len(result.evaluator_stats) == 2 - assert result.total_scores_created >= result.total_items_processed * 2 - - -# ============================================================================ -# COMPOSITE EVALUATOR TESTS -# ============================================================================ - - -def test_composite_evaluator_weighted_average(langfuse_client): - """Test composite evaluator that computes weighted average.""" - - def accuracy_evaluator(*, input, output, **kwargs): - return Evaluation(name="accuracy", value=0.8) - - def relevance_evaluator(*, input, output, **kwargs): - return Evaluation(name="relevance", value=0.9) - - def composite_evaluator(*, input, output, expected_output, metadata, evaluations): - weights = {"accuracy": 0.6, "relevance": 0.4} - total = sum( - e.value * weights.get(e.name, 0) - for e in evaluations - if isinstance(e.value, (int, float)) - ) - - return Evaluation( - name="composite_score", - value=total, - comment=f"Weighted average of {len(evaluations)} metrics", - ) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[accuracy_evaluator, relevance_evaluator], - composite_evaluator=composite_evaluator, - max_items=2, - ) - - # Should have both regular and composite scores - assert result.total_scores_created > 0 - assert result.total_composite_scores_created > 0 - assert result.total_scores_created > result.total_composite_scores_created - - -def test_composite_evaluator_pass_fail(langfuse_client): - """Test composite evaluator that implements pass/fail logic.""" - - def metric1_evaluator(*, input, output, **kwargs): - return Evaluation(name="metric1", value=0.9) - - def metric2_evaluator(*, input, output, **kwargs): - return Evaluation(name="metric2", value=0.7) - - def pass_fail_composite(*, input, output, expected_output, metadata, evaluations): - thresholds = {"metric1": 0.8, "metric2": 0.6} - - passes = all( - e.value >= thresholds.get(e.name, 0) - for e in evaluations - if isinstance(e.value, (int, float)) - ) - - return Evaluation( - name="passes_all_checks", - value=1.0 if passes else 0.0, - comment="All checks passed" if passes else "Some checks failed", - ) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[metric1_evaluator, metric2_evaluator], - composite_evaluator=pass_fail_composite, - max_items=2, - ) - - assert result.total_composite_scores_created > 0 - - -@pytest.mark.asyncio -async def test_async_composite_evaluator(langfuse_client): - """Test async composite evaluator.""" - - def evaluator1(*, input, output, **kwargs): - return Evaluation(name="eval1", value=0.8) - - async def async_composite(*, input, output, expected_output, metadata, evaluations): - await asyncio.sleep(0.01) # Simulate async processing - avg = sum( - e.value for e in evaluations if isinstance(e.value, (int, float)) - ) / len(evaluations) - return Evaluation(name="async_composite", value=avg) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[evaluator1], - composite_evaluator=async_composite, - max_items=2, - ) - - assert result.total_composite_scores_created > 0 - - -def test_composite_evaluator_with_no_evaluations(langfuse_client): - """Test composite evaluator when no evaluations are present.""" - - def always_failing_evaluator(*, input, output, **kwargs): - raise Exception("Always fails") - - def composite_evaluator(*, input, output, expected_output, metadata, evaluations): - # Should not be called if no evaluations succeed - return Evaluation(name="composite", value=0.0) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[always_failing_evaluator], - composite_evaluator=composite_evaluator, - max_items=2, - ) - - # Composite evaluator should not create scores if no evaluations - assert result.total_composite_scores_created == 0 - - -def test_composite_evaluator_failure_handling(langfuse_client): - """Test that composite evaluator failures are handled gracefully.""" - - def evaluator1(*, input, output, **kwargs): - return Evaluation(name="eval1", value=0.8) - - def failing_composite(*, input, output, expected_output, metadata, evaluations): - raise ValueError("Composite evaluator failed") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[evaluator1], - composite_evaluator=failing_composite, - max_items=2, - ) - - # Regular scores should still be created - assert result.total_scores_created > 0 - # But no composite scores - assert result.total_composite_scores_created == 0 - - -# ============================================================================ -# ERROR HANDLING TESTS -# ============================================================================ - - -def test_mapper_failure_skips_item(langfuse_client): - """Test that mapper failure causes item to be skipped.""" - - call_count = {"count": 0} - - def sometimes_failing_mapper(*, item): - call_count["count"] += 1 - if call_count["count"] % 2 == 0: - raise Exception("Mapper failed") - return simple_trace_mapper(item=item) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=sometimes_failing_mapper, - evaluators=[simple_evaluator], - max_items=4, - ) - - # Some items should fail, some should succeed - assert result.total_items_failed > 0 - assert result.total_items_processed > 0 - - -def test_evaluator_failure_continues(langfuse_client): - """Test that one evaluator failing doesn't stop others.""" - - def working_evaluator1(*, input, output, **kwargs): - return Evaluation(name="working1", value=0.8) - - def failing_evaluator(*, input, output, **kwargs): - raise Exception("Evaluator failed") - - def working_evaluator2(*, input, output, **kwargs): - return Evaluation(name="working2", value=0.9) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[working_evaluator1, failing_evaluator, working_evaluator2], - max_items=2, - ) - - # Working evaluators should still create scores - assert result.total_scores_created >= result.total_items_processed * 2 - - # Failing evaluator should be tracked - failing_stats = next( - s for s in result.evaluator_stats if s.name == "failing_evaluator" - ) - assert failing_stats.failed_runs > 0 - - -def test_all_evaluators_fail(langfuse_client): - """Test when all evaluators fail but item is still processed.""" - - def failing_evaluator1(*, input, output, **kwargs): - raise Exception("Failed 1") - - def failing_evaluator2(*, input, output, **kwargs): - raise Exception("Failed 2") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[failing_evaluator1, failing_evaluator2], - max_items=2, - ) - - # Items should be processed even if all evaluators fail - assert result.total_items_processed > 0 - # But no scores created - assert result.total_scores_created == 0 - # All evaluations failed - assert result.total_evaluations_failed > 0 - - -# ============================================================================ -# EDGE CASES TESTS -# ============================================================================ - - -def test_empty_results_handling(langfuse_client): - """Test batch evaluation when filter returns no items.""" - nonexistent_name = f"nonexistent-trace-{create_uuid()}" - nonexistent_filter = f'[{{"type": "string", "column": "name", "operator": "=", "value": "{nonexistent_name}"}}]' - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - filter=nonexistent_filter, - ) - - assert result.total_items_fetched == 0 - assert result.total_items_processed == 0 - assert result.total_scores_created == 0 - assert result.completed is True - assert result.has_more_items is False - - -def test_max_items_zero(langfuse_client): - """Test with max_items=0 (should process no items).""" - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=0, - ) - - assert result.total_items_fetched == 0 - assert result.total_items_processed == 0 - - -def test_evaluation_value_type_conversions(langfuse_client): - """Test that different evaluation value types are handled correctly.""" - - def multi_type_evaluator(*, input, output, **kwargs): - return [ - Evaluation(name="int_score", value=5), # int - Evaluation(name="float_score", value=0.85), # float - Evaluation(name="bool_score", value=True), # bool - Evaluation(name="none_score", value=None), # None - ] - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[multi_type_evaluator], + get_client().run_batched_evaluation( + mapper=recording_mapper, + evaluators=[length_evaluator], + filter=corpus.filter, + fields="core,basic", max_items=1, ) - # All value types should be converted and scores created - assert result.total_scores_created >= 4 - - -# ============================================================================ -# PAGINATION TESTS -# ============================================================================ - - -def test_pagination_with_max_items(langfuse_client): - """Test that max_items limit is respected.""" - # Create more traces to ensure we have enough data - for i in range(10): - with langfuse_client.start_as_current_observation( - name=f"pagination-test-{create_uuid()}" - ) as span: - with propagate_attributes(tags=["pagination_test"]): - span.set_trace_io( - input=f"Input {i}", - output=f"Output {i}", - ) - - langfuse_client.flush() - time.sleep(3) - - filter_json = '[{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["pagination_test"]}]' - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - filter=filter_json, - max_items=5, - fetch_batch_size=2, - ) - - # Should not exceed max_items - assert result.total_items_processed <= 5 - - -def test_has_more_items_flag(langfuse_client): - """Test that has_more_items flag is set correctly when max_items is reached.""" - # Create enough traces to exceed max_items - batch_tag = f"batch-test-{create_uuid()}" - for i in range(15): - with langfuse_client.start_as_current_observation( - name=f"more-items-test-{i}" - ) as span: - with propagate_attributes(tags=[batch_tag]): - span.set_trace_io( - input=f"Input {i}", - output=f"Output {i}", - ) - - langfuse_client.flush() - time.sleep(3) - - filter_json = f'[{{"type": "arrayOptions", "column": "tags", "operator": "any of", "value": ["{batch_tag}"]}}]' - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - filter=filter_json, - max_items=5, - fetch_batch_size=2, - ) - - # has_more_items should be True if we hit the limit - if result.total_items_fetched >= 5: - assert result.has_more_items is True - - -def test_fetch_batch_size_parameter(langfuse_client): - """Test that different fetch_batch_size values work correctly.""" - for batch_size in [1, 5, 10]: - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=3, - fetch_batch_size=batch_size, - ) - - # Should complete regardless of batch size - assert result.completed is True or result.total_items_processed > 0 - - -# ============================================================================ -# RESUME FUNCTIONALITY TESTS -# ============================================================================ - - -def test_resume_token_structure(langfuse_client): - """Test that BatchEvaluationResumeToken has correct structure.""" - resume_token = BatchEvaluationResumeToken( - scope="traces", - filter='{"test": "filter"}', - last_processed_timestamp="2024-01-01T00:00:00Z", - last_processed_id="trace-123", - items_processed=10, - ) - - assert resume_token.scope == "traces" - assert resume_token.filter == '{"test": "filter"}' - assert resume_token.last_processed_timestamp == "2024-01-01T00:00:00Z" - assert resume_token.last_processed_id == "trace-123" - assert resume_token.items_processed == 10 - - -# ============================================================================ -# CONCURRENCY TESTS -# ============================================================================ - - -def test_max_concurrency_parameter(langfuse_client): - """Test that max_concurrency parameter works correctly.""" - for concurrency in [1, 5, 10]: - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=3, - max_concurrency=concurrency, - ) - - # Should complete regardless of concurrency - assert result.completed is True or result.total_items_processed > 0 + assert len(seen) == 1 + assert seen[0].input is None + assert seen[0].output is None -# ============================================================================ -# STATISTICS TESTS -# ============================================================================ +def test_composite_evaluator_and_failures(corpus): + def failing_evaluator(**kwargs): + raise RuntimeError("intentional") + def composite(*, evaluations, **kwargs): + return Evaluation(name="composite", value=float(len(evaluations))) -def test_evaluator_stats_structure(langfuse_client): - """Test that EvaluatorStats has correct structure.""" - - def test_evaluator(*, input, output, **kwargs): - return Evaluation(name="test", value=1.0) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[test_evaluator], - max_items=2, + result = get_client().run_batched_evaluation( + mapper=io_mapper, + evaluators=[length_evaluator, failing_evaluator], + composite_evaluator=composite, + filter=corpus.filter, ) - assert len(result.evaluator_stats) == 1 - stats = result.evaluator_stats[0] - - # Check all fields exist - assert hasattr(stats, "name") - assert hasattr(stats, "total_runs") - assert hasattr(stats, "successful_runs") - assert hasattr(stats, "failed_runs") - assert hasattr(stats, "total_scores_created") - - # Check values - assert stats.name == "test_evaluator" - assert stats.total_runs == result.total_items_processed - assert stats.successful_runs == result.total_items_processed - assert stats.failed_runs == 0 - + item_count = 2 * TRACE_COUNT + assert result.total_items_processed == item_count + assert result.total_scores_created == item_count + assert result.total_composite_scores_created == item_count + assert result.total_evaluations_failed == item_count + stats = {s.name: s for s in result.evaluator_stats} + assert stats["failing_evaluator"].failed_runs == item_count -def test_evaluator_stats_tracking(langfuse_client): - """Test that evaluator statistics are tracked correctly.""" - - call_count = {"count": 0} - - def sometimes_failing_evaluator(*, input, output, **kwargs): - call_count["count"] += 1 - if call_count["count"] % 2 == 0: - raise Exception("Failed") - return Evaluation(name="test", value=1.0) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[sometimes_failing_evaluator], - max_items=4, - ) - - stats = result.evaluator_stats[0] - assert stats.total_runs == result.total_items_processed - assert stats.successful_runs > 0 - assert stats.failed_runs > 0 - assert stats.successful_runs + stats.failed_runs == stats.total_runs - - -def test_error_summary_aggregation(langfuse_client): - """Test that error types are aggregated correctly in error_summary.""" - - def failing_mapper(*, item): - raise ValueError("Mapper error") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=failing_mapper, - evaluators=[simple_evaluator], - max_items=3, - ) - - # Error summary should contain the error type - assert len(result.error_summary) > 0 - assert any("Error" in key for key in result.error_summary.keys()) - - -def test_failed_item_ids_collected(langfuse_client): - """Test that failed item IDs are collected.""" +def test_mapper_failures_are_reported_per_item(corpus): def failing_mapper(*, item): - raise Exception("Failed") + raise ValueError("intentional") - result = langfuse_client.run_batched_evaluation( - scope="traces", + result = get_client().run_batched_evaluation( mapper=failing_mapper, - evaluators=[simple_evaluator], - max_items=3, - ) - - assert len(result.failed_item_ids) > 0 - # Each failed ID should be a string - assert all(isinstance(item_id, str) for item_id in result.failed_item_ids) - - -# ============================================================================ -# PERFORMANCE TESTS -# ============================================================================ - - -def test_duration_tracking(langfuse_client): - """Test that duration is tracked correctly.""" - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=2, - ) - - assert result.duration_seconds > 0 - assert result.duration_seconds < 60 # Should complete quickly for small batch - - -def test_verbose_logging(langfuse_client): - """Test that verbose=True doesn't cause errors.""" - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[simple_evaluator], - max_items=2, - verbose=True, # Should log progress + evaluators=[length_evaluator], + filter=corpus.filter, ) assert result.completed is True + assert result.total_items_failed == 2 * TRACE_COUNT + assert set(result.failed_item_ids) == set(corpus.root_ids + corpus.child_ids) + assert result.error_summary == {"ValueError": 2 * TRACE_COUNT} -# ============================================================================ -# ITEM EVALUATIONS TESTS -# ============================================================================ - - -def test_item_evaluations_basic(langfuse_client): - """Test that item_evaluations dict contains correct structure.""" - - def test_evaluator(*, input, output, **kwargs): - return Evaluation(name="test_metric", value=0.5) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[test_evaluator], - max_items=3, - ) - - # Check that item_evaluations is a dict - assert isinstance(result.item_evaluations, dict) - - # Should have evaluations for each processed item - assert len(result.item_evaluations) == result.total_items_processed - - # Each entry should be a list of Evaluation objects - for item_id, evaluations in result.item_evaluations.items(): - assert isinstance(item_id, str) - assert isinstance(evaluations, list) - assert all(isinstance(e, Evaluation) for e in evaluations) - # Should have one evaluation per evaluator - assert len(evaluations) == 1 - assert evaluations[0].name == "test_metric" - - -def test_item_evaluations_multiple_evaluators(langfuse_client): - """Test item_evaluations with multiple evaluators.""" - - def accuracy_evaluator(*, input, output, **kwargs): - return Evaluation(name="accuracy", value=0.8) - - def relevance_evaluator(*, input, output, **kwargs): - return Evaluation(name="relevance", value=0.9) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[accuracy_evaluator, relevance_evaluator], - max_items=2, - ) - - # Check structure - assert len(result.item_evaluations) == result.total_items_processed - - # Each item should have evaluations from both evaluators - for item_id, evaluations in result.item_evaluations.items(): - assert len(evaluations) == 2 - eval_names = {e.name for e in evaluations} - assert eval_names == {"accuracy", "relevance"} - - -def test_item_evaluations_with_composite(langfuse_client): - """Test that item_evaluations includes composite evaluations.""" - - def base_evaluator(*, input, output, **kwargs): - return Evaluation(name="base_score", value=0.7) - - def composite_evaluator(*, input, output, expected_output, metadata, evaluations): - return Evaluation( - name="composite_score", - value=sum( - e.value for e in evaluations if isinstance(e.value, (int, float)) - ), - ) - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=simple_trace_mapper, - evaluators=[base_evaluator], - composite_evaluator=composite_evaluator, - max_items=2, - ) - - # Each item should have both base and composite evaluations - for item_id, evaluations in result.item_evaluations.items(): - assert len(evaluations) == 2 - eval_names = {e.name for e in evaluations} - assert eval_names == {"base_score", "composite_score"} - - # Verify composite scores were created - assert result.total_composite_scores_created > 0 - - -def test_item_evaluations_empty_on_failure(langfuse_client): - """Test that failed items don't appear in item_evaluations.""" - - def failing_mapper(*, item): - raise Exception("Mapper failed") - - result = langfuse_client.run_batched_evaluation( - scope="traces", - mapper=failing_mapper, - evaluators=[simple_evaluator], - max_items=3, +def test_filter_without_matches_completes_empty(): + result = get_client().run_batched_evaluation( + mapper=io_mapper, + evaluators=[length_evaluator], + filter=_tag_filter(f"nonexistent-{create_uuid()}"), ) - # All items failed, so item_evaluations should be empty - assert len(result.item_evaluations) == 0 - assert result.total_items_failed > 0 + assert result.completed is True + assert result.total_items_fetched == 0 + assert result.has_more_items is False + assert result.resume_token is None diff --git a/tests/e2e/test_core_sdk.py b/tests/e2e/test_core_sdk.py index 614d6da41..5330932c3 100644 --- a/tests/e2e/test_core_sdk.py +++ b/tests/e2e/test_core_sdk.py @@ -1,3 +1,4 @@ +import json import os import time from asyncio import gather @@ -5,17 +6,20 @@ from time import sleep import pytest -from tenacity import Retrying, stop_after_delay, wait_fixed from langfuse import Langfuse, propagate_attributes from langfuse._client.resource_manager import LangfuseResourceManager from langfuse._utils import _get_timestamp -from tests.support.api_wrapper import LangfuseAPI from tests.support.utils import ( create_uuid, get_api, - wait_for_result, - wait_for_trace, + get_observations, + get_root_observation, + get_scores, + user_metadata, + wait_for_observations, + wait_for_root_observation, + wait_for_scores, ) @@ -38,31 +42,28 @@ async def update_generation(i, langfuse: Langfuse): # End the generation generation.end() + return generation.trace_id + # Create Langfuse client langfuse = Langfuse() # Run concurrent operations - await gather(*(update_generation(i, langfuse) for i in range(100))) + trace_ids = await gather(*(update_generation(i, langfuse) for i in range(100))) langfuse.flush() - # Allow time for all operations to be processed - sleep(10) - # Verify that all spans were created properly - api = get_api() - for i in range(100): - # Find the observations with the expected name - observations = api.legacy.observations_v1.get_many(name=str(i)).data + for i, trace_id in enumerate(trace_ids): + observations = wait_for_observations(trace_id, min_count=2) - # Find generation observations (there should be at least one) generation_obs = [obs for obs in observations if obs.type == "GENERATION"] - assert len(generation_obs) > 0 + assert len(generation_obs) == 1 # Verify metadata observation = generation_obs[0] assert observation.name == str(i) - assert observation.metadata["count"] == i + assert user_metadata(observation)["count"] == i + assert get_root_observation(observations).trace_name == str(i) def test_flush(): @@ -80,14 +81,10 @@ def test_flush(): # Flush all pending spans to the Langfuse API langfuse.flush() - # Allow time for API to process - sleep(2) - # Verify traces were sent by checking they exist in the API - api = get_api() for i, trace_id in enumerate(trace_ids): - trace = api.trace.get(trace_id) - assert trace.name == str(i) + root = wait_for_root_observation(trace_id) + assert root.trace_name == str(i) def test_invalid_score_data_does_not_raise_exception(): @@ -152,21 +149,20 @@ def test_create_session_score(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - score = get_api().scores.get_by_id(score_id) + scores = wait_for_scores(session_id=session_id, id=score_id) - # find the score by name (server may transform the id format) - assert score is not None + assert len(scores) == 1 + score = scores[0] assert score.value == 1 assert score.data_type == "NUMERIC" - assert score.session_id == session_id + assert score.subject.kind == "session" + assert score.subject.id == session_id def test_create_numeric_score(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span and set trace properties with langfuse.start_as_current_observation(name="test-span") as span: @@ -202,22 +198,21 @@ def test_create_numeric_score(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) + wait_for_observations(trace_id, min_count=2) + scores = wait_for_scores(trace_id=trace_id, name="this-is-a-score") - # Find the score by name (server may transform the ID format) - score = next((s for s in trace["scores"] if s["name"] == "this-is-a-score"), None) - assert score is not None - assert score["value"] == 1 - assert score["dataType"] == "NUMERIC" - assert score["stringValue"] is None + assert len(scores) == 1 + score = scores[0] + assert score.id == score_id + assert score.value == 1 + assert score.data_type == "NUMERIC" + assert score.subject.kind == "trace" def test_create_boolean_score(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span and set trace properties with langfuse.start_as_current_observation(name="test-span") as span: @@ -231,7 +226,7 @@ def test_create_boolean_score(): # Ensure data is sent langfuse.flush() - api_wrapper.get_trace(trace_id) + wait_for_root_observation(trace_id) # Create a boolean score score_id = create_uuid() @@ -256,27 +251,17 @@ def test_create_boolean_score(): langfuse.flush() # Retrieve and verify - trace = api_wrapper.get_trace( - trace_id, - is_result_ready=lambda trace: any( - score["name"] == "this-is-a-score" for score in trace.get("scores", []) - ), - ) + scores = wait_for_scores(trace_id=trace_id, name="this-is-a-score") - # Find the score we created by name - created_score = next( - (s for s in trace["scores"] if s["name"] == "this-is-a-score"), None - ) - assert created_score is not None, "Score not found in trace" - assert created_score["id"] == score_id - assert created_score["dataType"] == "BOOLEAN" - assert created_score["value"] == 1 - assert created_score["stringValue"] == "True" + assert len(scores) == 1, "Score not found in trace" + created_score = scores[0] + assert created_score.id == score_id + assert created_score.data_type == "BOOLEAN" + assert created_score.value is True def test_create_categorical_score(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span and set trace properties with langfuse.start_as_current_observation(name="test-span") as span: @@ -290,7 +275,7 @@ def test_create_categorical_score(): # Ensure data is sent langfuse.flush() - api_wrapper.get_trace(trace_id) + wait_for_root_observation(trace_id) # Create a categorical score score_id = create_uuid() @@ -314,27 +299,17 @@ def test_create_categorical_score(): langfuse.flush() # Retrieve and verify - trace = api_wrapper.get_trace( - trace_id, - is_result_ready=lambda trace: any( - score["name"] == "this-is-a-score" for score in trace.get("scores", []) - ), - ) + scores = wait_for_scores(trace_id=trace_id, name="this-is-a-score") - # Find the score we created by name - created_score = next( - (s for s in trace["scores"] if s["name"] == "this-is-a-score"), None - ) - assert created_score is not None, "Score not found in trace" - assert created_score["id"] == score_id - assert created_score["dataType"] == "CATEGORICAL" - assert created_score["value"] == 0 - assert created_score["stringValue"] == "high score" + assert len(scores) == 1, "Score not found in trace" + created_score = scores[0] + assert created_score.id == score_id + assert created_score.data_type == "CATEGORICAL" + assert created_score.value == "high score" def test_create_text_score(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span and set trace properties with langfuse.start_as_current_observation(name="test-span") as span: @@ -372,30 +347,21 @@ def test_create_text_score(): # Ensure data is sent langfuse.flush() - # Retrieve and verify with retry - for attempt in Retrying( - stop=stop_after_delay(10), wait=wait_fixed(0.1), reraise=True - ): - with attempt: - trace = api_wrapper.get_trace(trace_id) - - # Find the score we created by name - created_score = next( - (s for s in trace["scores"] if s["name"] == "this-is-a-score"), None - ) - assert created_score is not None, "Score not found in trace" - assert created_score["id"] == score_id - assert created_score["dataType"] == "TEXT" + # Retrieve and verify + scores = wait_for_scores(trace_id=trace_id, name="this-is-a-score") - assert ( - created_score["stringValue"] - == "This is a detailed text evaluation of the output quality." - ) + assert len(scores) == 1, "Score not found in trace" + created_score = scores[0] + assert created_score.id == score_id + assert created_score.data_type == "TEXT" + assert ( + created_score.value + == "This is a detailed text evaluation of the output quality." + ) def test_create_score_with_custom_timestamp(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span and set trace properties with langfuse.start_as_current_observation(name="test-span") as span: @@ -409,7 +375,7 @@ def test_create_score_with_custom_timestamp(): # Ensure data is sent langfuse.flush() - api_wrapper.get_trace(trace_id) + wait_for_root_observation(trace_id) custom_timestamp = datetime.now(timezone.utc) - timedelta(hours=1) score_id = create_uuid() @@ -426,28 +392,16 @@ def test_create_score_with_custom_timestamp(): langfuse.flush() # Retrieve and verify - trace = api_wrapper.get_trace( - trace_id, - is_result_ready=lambda trace: any( - score["name"] == "custom-timestamp-score" - for score in trace.get("scores", []) - ), - ) + scores = wait_for_scores(trace_id=trace_id, name="custom-timestamp-score") - # Find the score we created by name - created_score = next( - (s for s in trace["scores"] if s["name"] == "custom-timestamp-score"), None - ) - assert created_score is not None, "Score not found in trace" - assert created_score["id"] == score_id - assert created_score["dataType"] == "NUMERIC" - assert created_score["value"] == 0.85 + assert len(scores) == 1, "Score not found in trace" + created_score = scores[0] + assert created_score.id == score_id + assert created_score.data_type == "NUMERIC" + assert created_score.value == 0.85 # Verify timestamp is close to our custom timestamp - # Parse the timestamp from the API response - response_timestamp = datetime.fromisoformat( - created_score["timestamp"].replace("Z", "+00:00") - ) + response_timestamp = created_score.timestamp # Check that the timestamps are within 1 second of each other # (allowing for some processing time and rounding) @@ -477,24 +431,14 @@ def test_create_trace(): langfuse.flush() # Retrieve the trace from the API - trace = LangfuseAPI().get_trace( - trace_id, - is_result_ready=lambda trace: ( - trace.get("name") == trace_name - and trace.get("userId") == "test" - and trace.get("metadata", {}).get("key") == "value" - and trace.get("tags") == ["tag1", "tag2"] - and trace.get("public") is True - ), - ) + root = wait_for_root_observation(trace_id) # Verify all trace properties - assert trace["name"] == trace_name - assert trace["userId"] == "test" - assert trace["metadata"]["key"] == "value" - assert trace["tags"] == ["tag1", "tag2"] - assert trace["public"] is True - assert True if not trace["externalId"] else False + assert root.trace_name == trace_name + assert root.user_id == "test" + assert user_metadata(root) == {"key": "value"} + assert root.tags == ["tag1", "tag2"] + assert root.public is True def test_create_update_trace(): @@ -525,23 +469,12 @@ def test_create_update_trace(): assert isinstance(trace_id, str) # Retrieve and verify trace - trace = wait_for_trace( - trace_id, - is_result_ready=lambda trace: ( - trace.name == trace_name - and trace.user_id == "test" - and trace.metadata is not None - and trace.metadata.get("key") == "value" - and trace.metadata.get("key2") == "value2" - and trace.public is True - ), - ) + root = wait_for_root_observation(trace_id) - assert trace.name == trace_name - assert trace.user_id == "test" - assert trace.metadata["key"] == "value" - assert trace.metadata["key2"] == "value2" - assert trace.public is True + assert root.trace_name == trace_name + assert root.user_id == "test" + assert user_metadata(root) == {"key": "value", "key2": "value2"} + assert root.public is True def test_create_update_current_trace(): @@ -549,14 +482,14 @@ def test_create_update_current_trace(): trace_name = create_uuid() - # Create initial span with trace properties using propagate_attributes and set_current_trace_io + # Create initial span with trace properties using propagate_attributes with langfuse.start_as_current_observation(name="test-span-current") as span: with propagate_attributes( trace_name=trace_name, user_id="test", metadata={"key": "value"}, ): - langfuse.set_current_trace_io(input="test_input") + langfuse.update_current_span(input="test_input") langfuse.set_current_trace_as_public() # Get trace ID for later reference trace_id = span.trace_id @@ -570,20 +503,18 @@ def test_create_update_current_trace(): # Ensure data is sent to the API langfuse.flush() - sleep(2) assert isinstance(trace_id, str) # Retrieve and verify trace - trace = get_api().trace.get(trace_id) + root = wait_for_root_observation(trace_id) # The 2nd update to the trace must not erase previously set attributes - assert trace.name == trace_name - assert trace.user_id == "test" - assert trace.metadata["key"] == "value" - assert trace.metadata["key2"] == "value2" - assert trace.public is True - assert trace.version == "1.0" - assert trace.input == "test_input" + assert root.trace_name == trace_name + assert root.user_id == "test" + assert user_metadata(root) == {"key": "value", "key2": "value2"} + assert root.public is True + assert root.version == "1.0" + assert root.input == "test_input" def test_create_generation(): @@ -620,19 +551,19 @@ def test_create_generation(): # Flush to ensure all data is sent langfuse.flush() - sleep(2) # Retrieve the trace from the API - trace = get_api().trace.get(trace_id) - - # Verify trace details - assert trace.name == "query-generation" - assert trace.user_id is None + observations = wait_for_observations(trace_id) - assert len(trace.observations) == 1 + assert len(observations) == 1 # Verify generation details - generation_api = trace.observations[0] + generation_api = observations[0] + + # Verify trace details + assert generation_api.is_root_observation is True + assert generation_api.trace_name == "query-generation" + assert generation_api.user_id is None assert generation_api.name == "query-generation" assert generation_api.start_time is not None @@ -707,15 +638,14 @@ def test_create_generation_complex( langfuse.flush() trace_id = generation.trace_id - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id) - assert trace.name == "query-generation" - assert trace.user_id is None + assert len(observations) == 1 - assert len(trace.observations) == 1 - - generation_api = trace.observations[0] + generation_api = observations[0] + assert generation_api.trace_name == "query-generation" + assert generation_api.user_id is None assert generation_api.id == generation.id assert generation_api.name == "query-generation" assert generation_api.input == [ @@ -727,13 +657,7 @@ def test_create_generation_complex( ] assert generation_api.output == [{"foo": "bar"}] - # Check if metadata exists and has tags before asserting - if ( - hasattr(generation_api, "metadata") - and generation_api.metadata is not None - and "tags" in generation_api.metadata - ): - assert generation_api.metadata["tags"] == ["yo"] + assert user_metadata(generation_api) == {"tags": ["yo"]} assert generation_api.start_time is not None assert generation_api.usage_details == {"input": 51, "output": 0, "total": 100} @@ -759,19 +683,18 @@ def test_create_span(): # Ensure all data is sent langfuse.flush() - sleep(2) # Retrieve from API - trace = get_api().trace.get(trace_id) - - # Verify trace details - assert trace.name == "span" - assert trace.user_id is None + observations = wait_for_observations(trace_id) - assert len(trace.observations) == 1 + assert len(observations) == 1 # Verify span details - span_api = trace.observations[0] + span_api = observations[0] + + # Verify trace details + assert span_api.trace_name == "span" + assert span_api.user_id is None assert span_api.id == span_id assert span_api.name == "span" @@ -784,7 +707,6 @@ def test_create_span(): def test_score_trace(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() trace_name = create_uuid() @@ -803,20 +725,17 @@ def test_score_trace(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) - - assert trace["name"] == trace_name + assert wait_for_root_observation(trace_id).trace_name == trace_name - # Find the score we created by name (server may create additional auto-scores) - score = next((s for s in trace["scores"] if s["name"] == "valuation"), None) - assert score is not None - assert score["value"] == 0.5 - assert score["comment"] == "This is a comment" - assert score["observationId"] is None - assert score["dataType"] == "NUMERIC" + scores = wait_for_scores(trace_id=trace_id, name="valuation") + assert len(scores) == 1 + score = scores[0] + assert score.value == 0.5 + assert score.comment == "This is a comment" + assert score.subject.kind == "trace" + assert score.data_type == "NUMERIC" def test_score_trace_nested_trace(): @@ -839,19 +758,16 @@ def test_score_trace_nested_trace(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + assert wait_for_root_observation(trace_id).trace_name == trace_name - assert trace.name == trace_name - - # Find the score we created by name (server may create additional auto-scores) - score = next((s for s in trace.scores if s.name == "valuation"), None) - assert score is not None + scores = wait_for_scores(trace_id=trace_id, name="valuation") + assert len(scores) == 1 + score = scores[0] assert score.value == 0.5 assert score.comment == "This is a comment" - assert score.observation_id is None # API returns this field name + assert score.subject.kind == "trace" assert score.data_type == "NUMERIC" @@ -882,25 +798,23 @@ def test_score_trace_nested_observation(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) - - assert trace.name == trace_name + assert wait_for_root_observation(trace_id).trace_name == trace_name - # Find the score we created by name (server may create additional auto-scores) - score = next((s for s in trace.scores if s.name == "valuation"), None) - assert score is not None + scores = wait_for_scores(trace_id=trace_id, name="valuation") + assert len(scores) == 1 + score = scores[0] assert score.value == 0.5 assert score.comment == "This is a comment" - assert score.observation_id == child_span_id # API returns this field name + assert score.subject.kind == "observation" + assert score.subject.id == child_span_id + assert score.subject.trace_id == trace_id assert score.data_type == "NUMERIC" def test_score_span(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span span = langfuse.start_observation( @@ -928,20 +842,19 @@ def test_score_span(): # Ensure data is sent langfuse.flush() - sleep(3) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) + assert len(wait_for_observations(trace_id)) == 1 - assert len(trace["observations"]) == 1 - - # Find the score we created by name (server may create additional auto-scores) - score = next((s for s in trace["scores"] if s["name"] == "valuation"), None) - assert score is not None - assert score["value"] == 1 - assert score["comment"] == "This is a comment" - assert score["observationId"] == span_id - assert score["dataType"] == "NUMERIC" + scores = wait_for_scores(trace_id=trace_id, observation_id=span_id) + assert len(scores) == 1 + score = scores[0] + assert score.name == "valuation" + assert score.value == 1 + assert score.comment == "This is a comment" + assert score.subject.kind == "observation" + assert score.subject.id == span_id + assert score.data_type == "NUMERIC" def test_create_trace_and_span(): @@ -963,16 +876,15 @@ def test_create_trace_and_span(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=2) - assert trace.name == trace_name - assert len(trace.observations) == 2 # Parent span and child span + assert get_root_observation(observations).trace_name == trace_name + assert len(observations) == 2 # Parent span and child span # Find the child span - child_spans = [obs for obs in trace.observations if obs.name == "span"] + child_spans = [obs for obs in observations if obs.name == "span"] assert len(child_spans) == 1 span = child_spans[0] @@ -989,7 +901,7 @@ def test_create_trace_and_generation(): # Create parent span and set trace properties with langfuse.start_as_current_observation(name=trace_name) as parent_span: with propagate_attributes(trace_name=trace_name, session_id="test-session-id"): - parent_span.set_trace_io(input={"key": "value"}) + parent_span.update(input={"key": "value"}) # Create a generation as child generation = parent_span.start_observation( @@ -1004,35 +916,30 @@ def test_create_trace_and_generation(): # Ensure data is sent langfuse.flush() - sleep(2) - # Retrieve traces in two ways - dbTrace = get_api().trace.get(trace_id) - getTrace = get_api().trace.get( - trace_id - ) # Using API as direct getTrace not available + # Retrieve and verify + observations = wait_for_observations(trace_id, min_count=2) + root = get_root_observation(observations) # Verify trace details - assert dbTrace.name == trace_name - assert len(dbTrace.observations) == 2 # Parent span and generation - assert getTrace.name == trace_name - assert len(getTrace.observations) == 2 - assert getTrace.session_id == "test-session-id" + assert root.trace_name == trace_name + assert len(observations) == 2 # Parent span and generation + assert root.session_id == "test-session-id" # Find the generation - generations = [obs for obs in getTrace.observations if obs.name == "generation"] + generations = [obs for obs in observations if obs.name == "generation"] assert len(generations) == 1 generation = generations[0] assert generation.name == "generation" assert generation.trace_id == trace_id + assert generation.session_id == "test-session-id" assert generation.start_time is not None - assert getTrace.input == {"key": "value"} + assert root.input == {"key": "value"} def test_create_generation_and_trace(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() trace_name = create_uuid() @@ -1063,23 +970,24 @@ def test_create_generation_and_trace(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) - - assert trace["name"] == trace_name + observations = wait_for_observations(trace_id, min_count=2) # We should have 2 observations (the generation and the span for updating trace) - assert len(trace["observations"]) == 2 + assert len(observations) == 2 + + trace_update_spans = [obs for obs in observations if obs.name == "trace-update"] + assert len(trace_update_spans) == 1 + assert trace_update_spans[0].trace_name == trace_name # Find the generation - generations = [obs for obs in trace["observations"] if obs["name"] == "generation"] + generations = [obs for obs in observations if obs.name == "generation"] assert len(generations) == 1 generation_obs = generations[0] - assert generation_obs["name"] == "generation" - assert generation_obs["traceId"] == trace["id"] + assert generation_obs.name == "generation" + assert generation_obs.trace_id == trace_id def test_create_span_and_get_observation(): @@ -1096,12 +1004,12 @@ def test_create_span_and_get_observation(): # Flush and wait langfuse.flush() - sleep(2) - # Use API to fetch the observation by ID - observation = get_api().legacy.observations_v1.get(span_id) + observations = wait_for_observations(span.trace_id) # Verify observation properties + assert len(observations) == 1 + observation = observations[0] assert observation.name == "span" assert observation.id == span_id @@ -1123,20 +1031,19 @@ def test_update_generation(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id) # Verify trace properties - assert trace.name == "generation" - assert len(trace.observations) == 1 + assert len(observations) == 1 # Verify generation updates - retrieved_generation = trace.observations[0] + retrieved_generation = observations[0] + assert retrieved_generation.trace_name == "generation" assert retrieved_generation.name == "generation" assert retrieved_generation.trace_id == trace_id - assert retrieved_generation.metadata["dict"] == "value" + assert user_metadata(retrieved_generation) == {"dict": "value"} # Note: With OTEL, we can't verify exact start times from manually set timestamps, # as they are managed internally by the OTEL SDK @@ -1159,20 +1066,19 @@ def test_update_span(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id) # Verify trace properties - assert trace.name == "span" - assert len(trace.observations) == 1 + assert len(observations) == 1 # Verify span updates - retrieved_span = trace.observations[0] + retrieved_span = observations[0] + assert retrieved_span.trace_name == "span" assert retrieved_span.name == "span" assert retrieved_span.trace_id == trace_id - assert retrieved_span.metadata["dict"] == "value" + assert user_metadata(retrieved_span) == {"dict": "value"} def test_create_span_and_generation(): @@ -1197,17 +1103,16 @@ def test_create_span_and_generation(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=2) # Verify trace details - assert len(trace.observations) == 2 + assert len(observations) == 2 # Find span and generation - spans = [obs for obs in trace.observations if obs.name == "span"] - generations = [obs for obs in trace.observations if obs.name == "generation"] + spans = [obs for obs in observations if obs.name == "span"] + generations = [obs for obs in observations if obs.name == "generation"] assert len(spans) == 1 assert len(generations) == 1 @@ -1222,7 +1127,6 @@ def test_create_span_and_generation(): def test_create_trace_with_id_and_generation(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() trace_name = create_uuid() @@ -1244,28 +1148,27 @@ def test_create_trace_with_id_and_generation(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) + observations = wait_for_observations(trace_id, min_count=2) # Verify trace properties - assert trace["name"] == trace_name - assert trace["id"] == trace_id - assert len(trace["observations"]) == 2 # Parent span and generation + root = get_root_observation(observations) + assert root.trace_name == trace_name + assert root.trace_id == trace_id + assert len(observations) == 2 # Parent span and generation # Find the generation - generations = [obs for obs in trace["observations"] if obs["name"] == "generation"] + generations = [obs for obs in observations if obs.name == "generation"] assert len(generations) == 1 gen = generations[0] - assert gen["name"] == "generation" - assert gen["traceId"] == trace["id"] + assert gen.name == "generation" + assert gen.trace_id == trace_id def test_end_generation(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a generation generation = langfuse.start_observation( @@ -1294,21 +1197,14 @@ def test_end_generation(): langfuse.flush() # Retrieve and verify - trace = api_wrapper.get_trace( - trace_id, - is_result_ready=lambda trace: any( - obs["name"] == "query-generation" for obs in trace.get("observations", []) - ), - ) + observations = wait_for_observations(trace_id) # Find generation by name - generations = [ - obs for obs in trace["observations"] if obs["name"] == "query-generation" - ] + generations = [obs for obs in observations if obs.name == "query-generation"] assert len(generations) == 1 gen = generations[0] - assert gen["endTime"] is not None + assert gen.end_time is not None def test_end_generation_with_data(): @@ -1353,15 +1249,12 @@ def test_end_generation_with_data(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - fetched_trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=2) # Find generation by name - generations = [ - obs for obs in fetched_trace.observations if obs.name == "query-generation" - ] + generations = [obs for obs in observations if obs.name == "query-generation"] assert len(generations) == 1 generation = generations[0] @@ -1371,7 +1264,7 @@ def test_end_generation_with_data(): 2023, 1, 1, 12, 3, tzinfo=timezone.utc ) assert generation.name == "query-generation" - assert generation.metadata["dict"] == "value" + assert user_metadata(generation) == {"dict": "value"} assert generation.level == "ERROR" assert generation.status_message == "Generation ended" assert generation.version == "1.0" @@ -1379,12 +1272,9 @@ def test_end_generation_with_data(): assert generation.model_parameters == {"param1": "value1", "param2": "value2"} assert generation.input == [{"test_input_key": "test_input_value"}] assert generation.output == {"test_output_key": "test_output_value"} - assert generation.usage.input == 100 - assert generation.usage.output == 200 - assert generation.usage.total == 500 - assert generation.calculated_input_cost == 111 - assert generation.calculated_output_cost == 222 - assert generation.calculated_total_cost == 444 + assert generation.usage_details == {"input": 100, "output": 200, "total": 500} + assert generation.cost_details == {"input": 111, "output": 222, "total": 444} + assert generation.total_cost == 444 def test_end_generation_with_openai_token_format(): @@ -1418,31 +1308,26 @@ def test_end_generation_with_openai_token_format(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id) # Find generation - generations = [obs for obs in trace.observations if obs.name == "query-generation"] + generations = [obs for obs in observations if obs.name == "query-generation"] assert len(generations) == 1 generation_api = generations[0] # Verify properties were converted correctly assert generation_api.end_time is not None - assert generation_api.usage.input == 100 # prompt_tokens mapped to input - assert generation_api.usage.output == 200 # completion_tokens mapped to output - assert generation_api.usage.total == 500 - assert generation_api.usage.unit == "TOKENS" # Default unit for OpenAI format - assert generation_api.calculated_input_cost == 111 - assert generation_api.calculated_output_cost == 222 - assert generation_api.calculated_total_cost == 444 + # OpenAI-style keys are mapped to input/output/total + assert generation_api.usage_details == {"input": 100, "output": 200, "total": 500} + assert generation_api.cost_details == {"input": 111, "output": 222, "total": 444} + assert generation_api.total_cost == 444 def test_end_span(): langfuse = Langfuse() - api_wrapper = LangfuseAPI() # Create a span span = langfuse.start_observation( @@ -1460,19 +1345,18 @@ def test_end_span(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = api_wrapper.get_trace(trace_id) + observations = wait_for_observations(trace_id) # Find span - spans = [obs for obs in trace["observations"] if obs["name"] == "span"] + spans = [obs for obs in observations if obs.name == "span"] assert len(spans) == 1 span_api = spans[0] # Verify end time was set - assert span_api["endTime"] is not None + assert span_api.end_time is not None def test_end_span_with_data(): @@ -1495,21 +1379,19 @@ def test_end_span_with_data(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id) # Find span - spans = [obs for obs in trace.observations if obs.name == "span"] + spans = [obs for obs in observations if obs.name == "span"] assert len(spans) == 1 span_api = spans[0] # Verify end time and metadata were updated assert span_api.end_time is not None - assert span_api.metadata["dict"] == "value" - assert span_api.metadata["interface"] == "whatsapp" + assert user_metadata(span_api) == {"dict": "value", "interface": "whatsapp"} def test_get_generations(): @@ -1535,16 +1417,15 @@ def test_get_generations(): # Ensure data is sent langfuse.flush() - sleep(3) # Fetch generations using API - generations = get_api().legacy.observations_v1.get_many(name=generation_name) + generations = wait_for_observations(name=generation_name) # Verify fetched generation matches what we created - assert len(generations.data) == 1 - assert generations.data[0].name == generation_name - assert generations.data[0].input == "great-prompt" - assert generations.data[0].output == "great-completion" + assert len(generations) == 1 + assert generations[0].name == generation_name + assert generations[0].input == "great-prompt" + assert generations[0].output == "great-completion" def test_get_generations_by_user(): @@ -1574,18 +1455,15 @@ def test_get_generations_by_user(): # Ensure data is sent langfuse.flush() - sleep(3) # Fetch generations by user ID using the API - generations = get_api().legacy.observations_v1.get_many( - user_id=user_id, type="GENERATION" - ) + generations = wait_for_observations(user_id=user_id, type="GENERATION") # Verify fetched generation matches what we created - assert len(generations.data) == 1 - assert generations.data[0].name == generation_name - assert generations.data[0].input == "great-prompt" - assert generations.data[0].output == "great-completion" + assert len(generations) == 1 + assert generations[0].name == generation_name + assert generations[0].input == "great-prompt" + assert generations[0].output == "great-completion" def test_kwargs(): @@ -1614,16 +1492,17 @@ def test_kwargs(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - observation = get_api().legacy.observations_v1.get(span_id) + observations = wait_for_observations(span.trace_id) + assert [observation.id for observation in observations] == [span_id] + observation = observations[0] # Verify kwargs were properly set as attributes assert observation.start_time is not None assert observation.input == {"key": "value"} assert observation.output == {"key": "value"} - assert observation.metadata["interface"] == "whatsapp" + assert user_metadata(observation) == {"interface": "whatsapp"} @pytest.mark.skip("Flaky") @@ -1660,16 +1539,13 @@ def test_timezone_awareness(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=4) # Verify timestamps are in UTC regardless of local timezone - assert ( - len(trace.observations) == 4 - ) # Parent span, child span, generation, and event - for observation in trace.observations: + assert len(observations) == 4 # Parent span, child span, generation, and event + for observation in observations: # Check that start_time is within 5 seconds of current time delta = observation.start_time - utc_now assert delta.seconds < 5 @@ -1720,16 +1596,13 @@ def test_timezone_awareness_setting_timestamps(): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - trace = get_api().trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=4) # Verify timestamps are in UTC regardless of local timezone - assert ( - len(trace.observations) == 4 - ) # Parent span, child span, generation, and event - for observation in trace.observations: + assert len(observations) == 4 # Parent span, child span, generation, and event + for observation in observations: # Check that start_time is within 5 seconds of current time delta = abs((utc_now - observation.start_time).total_seconds()) assert delta < 5 @@ -1762,17 +1635,17 @@ def test_get_trace_by_session_id(): # Ensure data is sent langfuse.flush() - sleep(2) - # Retrieve the trace using the session_id - traces = get_api().trace.list(session_id=session_id) + # Retrieve the trace's observations using the session_id + observations = wait_for_observations(session_id=session_id) # Verify that the trace was retrieved correctly - assert len(traces.data) == 1 - retrieved_trace = traces.data[0] - assert retrieved_trace.name == trace_name - assert retrieved_trace.session_id == session_id - assert retrieved_trace.id == trace_id + assert len(observations) == 1 + retrieved_root = observations[0] + assert retrieved_root.is_root_observation is True + assert retrieved_root.trace_name == trace_name + assert retrieved_root.session_id == session_id + assert retrieved_root.trace_id == trace_id def test_fetch_trace(): @@ -1789,15 +1662,12 @@ def test_fetch_trace(): # Ensure data is sent langfuse.flush() - sleep(2) - # Fetch the trace using the get_api client - # Note: In the OTEL-based client, we use the API client directly - trace = get_api().trace.get(trace_id) + root = wait_for_root_observation(trace_id) # Verify trace properties - assert trace.id == trace_id - assert trace.name == name + assert root.trace_id == trace_id + assert root.trace_name == name def test_fetch_traces(): @@ -1812,7 +1682,7 @@ def test_fetch_traces(): # First trace with langfuse.start_as_current_observation(name="test1") as span: with propagate_attributes(trace_name=name, session_id="session-1"): - span.set_trace_io(input={"key": "value"}, output="output-value") + span.update(input={"key": "value"}, output="output-value") trace_ids.append(span.trace_id) sleep(1) # Ensure traces have different timestamps @@ -1820,7 +1690,7 @@ def test_fetch_traces(): # Second trace with langfuse.start_as_current_observation(name="test2") as span: with propagate_attributes(trace_name=name, session_id="session-1"): - span.set_trace_io(input={"key": "value"}, output="output-value") + span.update(input={"key": "value"}, output="output-value") trace_ids.append(span.trace_id) sleep(1) # Ensure traces have different timestamps @@ -1828,7 +1698,7 @@ def test_fetch_traces(): # Third trace with langfuse.start_as_current_observation(name="test3") as span: with propagate_attributes(trace_name=name, session_id="session-1"): - span.set_trace_io(input={"key": "value"}, output="output-value") + span.update(input={"key": "value"}, output="output-value") trace_ids.append(span.trace_id) # Ensure data is sent @@ -1836,38 +1706,50 @@ def test_fetch_traces(): expected_trace_ids = set(trace_ids) api = get_api(retry=False) + trace_name_filter = json.dumps( + [{"type": "string", "column": "traceName", "operator": "=", "value": name}] + ) - # Fetch all traces with the same name. - all_traces = wait_for_result( - lambda: api.trace.list(name=name, limit=10), - is_result_ready=lambda response: ( - {trace.id for trace in response.data} == expected_trace_ids + # Fetch the root observations of all traces with the same name. + roots = wait_for_observations( + filter=trace_name_filter, + is_result_ready=lambda observations: ( + {o.trace_id for o in observations} == expected_trace_ids ), ) # Verify we got all traces - assert len(all_traces.data) == 3 + assert len(roots) == 3 # Verify trace properties - for trace in all_traces.data: - assert trace.name == name - assert trace.session_id == "session-1" - assert trace.input == {"key": "value"} - assert trace.output == "output-value" - - # Test pagination by fetching the first three pages one at a time and - # confirming they collectively cover the created traces. - paginated_ids = set() - for page in range(1, 4): - paginated_response = wait_for_result( - lambda page=page: api.trace.list(name=name, limit=1, page=page), - is_result_ready=lambda response: ( - len(response.data) == 1 and response.data[0].id in expected_trace_ids - ), + for root in roots: + assert root.is_root_observation is True + assert root.trace_name == name + assert root.session_id == "session-1" + assert root.input == {"key": "value"} + assert root.output == "output-value" + + # Test cursor pagination by walking pages of one item and confirming they + # collectively cover the created traces. + paginated_ids = [] + cursor = None + for _ in range(3): + page = api.observations.get_many( + filter=trace_name_filter, limit=1, cursor=cursor + ) + assert len(page.data) == 1 + paginated_ids.append(page.data[0].trace_id) + cursor = page.meta.cursor + + assert set(paginated_ids) == expected_trace_ids + assert len(paginated_ids) == 3 + if cursor is not None: + assert ( + api.observations.get_many( + filter=trace_name_filter, limit=1, cursor=cursor + ).data + == [] ) - paginated_ids.add(paginated_response.data[0].id) - - assert paginated_ids == expected_trace_ids def test_get_observation(): @@ -1890,10 +1772,12 @@ def test_get_observation(): # Ensure data is sent langfuse.flush() - sleep(2) # Fetch the observation using the API - observation = get_api().legacy.observations_v1.get(generation_id) + observations = wait_for_observations(parent_span.trace_id, min_count=2) + matching = [o for o in observations if o.id == generation_id] + assert len(matching) == 1 + observation = matching[0] # Verify observation properties assert observation.id == generation_id @@ -1926,18 +1810,18 @@ def test_get_observations(): # Fetch observations using the API expected_generation_ids = {gen1_id, gen2_id} - observations = wait_for_result( - lambda: api.legacy.observations_v1.get_many(name=name, limit=10), - is_result_ready=lambda response: expected_generation_ids.issubset( - {obs.id for obs in response.data} + observations = wait_for_observations( + name=name, + is_result_ready=lambda observations: expected_generation_ids.issubset( + {obs.id for obs in observations} ), ) # Verify fetched observations - assert len(observations.data) == 2 + assert len(observations) == 2 # Filter for just the generations - generations = [obs for obs in observations.data if obs.type == "GENERATION"] + generations = [obs for obs in observations if obs.type == "GENERATION"] assert len(generations) == 2 # Verify the generation IDs match what we created @@ -1945,91 +1829,32 @@ def test_get_observations(): assert gen1_id in gen_ids assert gen2_id in gen_ids - # Test pagination by confirming both created generations can be reached - # across separate pages. - paginated_ids = set() - for page in range(1, 3): - paginated_response = wait_for_result( - lambda page=page: api.legacy.observations_v1.get_many( - name=name, limit=1, page=page - ), - is_result_ready=lambda response: ( - len(response.data) == 1 - and response.data[0].id in expected_generation_ids - ), - ) - paginated_ids.add(paginated_response.data[0].id) - - assert paginated_ids == expected_generation_ids - - -def test_get_trace_not_found(): - # Attempt to fetch a non-existent trace using the API - with pytest.raises(Exception): - get_api(retry=False).trace.get(create_uuid()) - + # Test cursor pagination by confirming both created generations can be + # reached across separate pages. + first_page = api.observations.get_many(name=name, limit=1) + assert len(first_page.data) == 1 + assert first_page.meta.cursor is not None + second_page = api.observations.get_many( + name=name, limit=1, cursor=first_page.meta.cursor + ) + assert len(second_page.data) == 1 -def test_get_observation_not_found(): - # Attempt to fetch a non-existent observation using the API - with pytest.raises(Exception): - get_api(retry=False).legacy.observations_v1.get(create_uuid()) + assert {first_page.data[0].id, second_page.data[0].id} == expected_generation_ids -def test_get_traces_empty(): - # Fetch traces with a filter that should return no results - response = get_api(retry=False).trace.list(name=create_uuid()) +def test_get_observations_for_unknown_trace_is_empty(): + response = get_api(retry=False).observations.get_many(trace_id=create_uuid()) - assert len(response.data) == 0 - assert response.meta.total_items == 0 + assert response.data == [] + assert response.meta.cursor is None def test_get_observations_empty(): # Fetch observations with a filter that should return no results - response = get_api(retry=False).legacy.observations_v1.get_many(name=create_uuid()) + response = get_api(retry=False).observations.get_many(name=create_uuid()) - assert len(response.data) == 0 - assert response.meta.total_items == 0 - - -def test_get_sessions(): - langfuse = Langfuse() - - # unique name - name = create_uuid() - session1 = create_uuid() - session2 = create_uuid() - session3 = create_uuid() - - # Create multiple traces with different session IDs - # Create first trace - with langfuse.start_as_current_observation(name=name): - with propagate_attributes(trace_name=name, session_id=session1): - pass - - # Create second trace - with langfuse.start_as_current_observation(name=name): - with propagate_attributes(trace_name=name, session_id=session2): - pass - - # Create third trace - with langfuse.start_as_current_observation(name=name): - with propagate_attributes(trace_name=name, session_id=session3): - pass - - langfuse.flush() - - # Fetch sessions - sleep(3) - response = get_api().sessions.list() - - # Assert the structure of the response, cannot check for the exact number of sessions as the table is not cleared between tests - assert hasattr(response, "data") - assert hasattr(response, "meta") - assert isinstance(response.data, list) - - # fetch only one, cannot check for the exact number of sessions as the table is not cleared between tests - response = get_api().sessions.list(limit=1, page=2) - assert len(response.data) == 1 + assert response.data == [] + assert response.meta.cursor is None @pytest.mark.skip( @@ -2037,7 +1862,6 @@ def test_get_sessions(): ) def test_create_trace_sampling_zero(): langfuse = Langfuse(sample_rate=0) - api_wrapper = LangfuseAPI() trace_name = create_uuid() # Create a span with trace properties - with sample_rate=0, this will not be sent to the API @@ -2061,12 +1885,9 @@ def test_create_trace_sampling_zero(): langfuse.flush() sleep(2) - # Try to fetch the trace - should fail as it wasn't sent to the API - fetched_trace = api_wrapper.get_trace(trace_id) - assert fetched_trace == { - "error": "LangfuseNotFoundError", - "message": f"Trace {trace_id} not found within authorized project", - } + # The trace's observations must not exist as they were never sent to the API + assert get_observations(trace_id=trace_id) == [] + assert get_scores(trace_id=trace_id) == [] def test_mask_function(request): @@ -2083,16 +1904,15 @@ def mask_func(data): return data langfuse = Langfuse(mask=mask_func) - api_wrapper = LangfuseAPI() # Create a root span with trace properties with langfuse.start_as_current_observation(name="test-span") as root_span: with propagate_attributes(trace_name="test_trace"): - root_span.set_trace_io(input={"sensitive": "data"}) + root_span.update(input={"sensitive": "data"}) # Get trace ID for later use trace_id = root_span.trace_id # Add output to the trace - root_span.set_trace_io(output={"more": "sensitive"}) + root_span.update(output={"more": "sensitive"}) # Create a generation as child gen = root_span.start_observation( @@ -2112,44 +1932,39 @@ def mask_func(data): # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - fetched_trace = api_wrapper.get_trace(trace_id) - assert fetched_trace["input"] == {"sensitive": "MASKED"} - assert fetched_trace["output"] == {"more": "MASKED"} + observations = wait_for_observations(trace_id, min_count=3) + fetched_root = get_root_observation(observations) + assert fetched_root.input == {"sensitive": "MASKED"} + assert fetched_root.output == {"more": "MASKED"} - fetched_gen = [ - o for o in fetched_trace["observations"] if o["type"] == "GENERATION" - ][0] - assert fetched_gen["input"] == {"prompt": "MASKED"} - assert fetched_gen["output"] == "MASKED" + fetched_gen = [o for o in observations if o.type == "GENERATION"][0] + assert fetched_gen.input == {"prompt": "MASKED"} + assert fetched_gen.output == "MASKED" fetched_span = [ - o - for o in fetched_trace["observations"] - if o["type"] == "SPAN" and o["name"] == "test_span" + o for o in observations if o.type == "SPAN" and o.name == "test_span" ][0] - assert fetched_span["input"] == {"data": "MASKED"} - assert fetched_span["output"] == "MASKED" + assert fetched_span.input == {"data": "MASKED"} + assert fetched_span.output == "MASKED" # Create a root span with trace properties with langfuse.start_as_current_observation(name="test-span") as root_span: with propagate_attributes(trace_name="test_trace"): - root_span.set_trace_io(input={"should_raise": "data"}) + root_span.update(input={"should_raise": "data"}) # Get trace ID for later use trace_id = root_span.trace_id # Add output to the trace - root_span.set_trace_io(output={"should_raise": "sensitive"}) + root_span.update(output={"should_raise": "sensitive"}) # Ensure data is sent langfuse.flush() - sleep(2) # Retrieve and verify - fetched_trace = api_wrapper.get_trace(trace_id) - assert fetched_trace["input"] == "" - assert fetched_trace["output"] == "" + fetched_root = wait_for_root_observation(trace_id) + assert fetched_root.input == "" + assert fetched_root.output == "" def test_get_project_id(): @@ -2218,13 +2033,11 @@ def test_start_as_current_observation_types(): pass langfuse.flush() - sleep(2) - api = get_api() - trace = api.trace.get(trace_id) + observations = wait_for_observations(trace_id, min_count=len(observation_types) + 1) # Check we have all expected observation types - found_types = {obs.type for obs in trace.observations} + found_types = {obs.type for obs in observations} expected_types = {obs_type.upper() for obs_type in observation_types} | { "SPAN" } # includes parent span @@ -2234,12 +2047,12 @@ def test_start_as_current_observation_types(): # Verify each specific observation exists for obs_type in observation_types: - observations = [ + matching = [ obs - for obs in trace.observations + for obs in observations if obs.name == f"test-{obs_type}" and obs.type == obs_type.upper() ] - assert len(observations) == 1, f"Expected one {obs_type.upper()} observation" + assert len(matching) == 1, f"Expected one {obs_type.upper()} observation" def test_that_generation_like_properties_are_actually_created(): @@ -2296,21 +2109,22 @@ def test_that_generation_like_properties_are_actually_created(): langfuse.flush() - api = get_api() - trace = api.trace.get(trace_id) + observations = wait_for_observations( + trace_id, min_count=len(generation_like_types) + 1 + ) # Verify that the properties are persisted in the API for generation-like types for obs_type in generation_like_types: - observations = [ + matching = [ obs - for obs in trace.observations + for obs in observations if obs.name == f"test-{obs_type}" and obs.type == obs_type.upper() ] - assert len(observations) == 1, ( - f"Expected one {obs_type.upper()} observation, but found {len(observations)}" + assert len(matching) == 1, ( + f"Expected one {obs_type.upper()} observation, but found {len(matching)}" ) - obs = observations[0] + obs = matching[0] assert obs.model == test_model, f"{obs_type} should have model property" assert obs.model_parameters == test_model_parameters, ( diff --git a/tests/e2e/test_decorators.py b/tests/e2e/test_decorators.py index 9fac4cf22..a33a4f3d5 100644 --- a/tests/e2e/test_decorators.py +++ b/tests/e2e/test_decorators.py @@ -15,7 +15,11 @@ from langfuse._client.resource_manager import LangfuseResourceManager from langfuse.langchain import CallbackHandler from langfuse.media import LangfuseMedia -from tests.support.utils import get_api, wait_for_trace +from tests.support.utils import ( + get_observations, + user_metadata, + wait_for_trace_snapshot, +) mock_metadata = {"key": "metadata"} mock_deep_metadata = {"key": "mock_deep_metadata"} @@ -100,7 +104,7 @@ def level_1_function(*args, **kwargs): assert result == "level_1" # Wrapped function returns correctly # ID setting for span or trace - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=3) assert len(trace_data.observations) == 3 # trace parameters if set anywhere in the call stack @@ -133,7 +137,7 @@ def level_1_function(*args, **kwargs): assert level_3_observation.name == "level_3" assert level_3_observation.metadata["key"] == mock_deep_metadata["key"] assert level_3_observation.type == "GENERATION" - assert level_3_observation.calculated_total_cost > 0 + assert level_3_observation.total_cost > 0 assert level_3_observation.output == "mock_output" assert level_3_observation.version == "version-1" @@ -182,7 +186,7 @@ def level_1_function(*args, **kwargs): assert result == "level_1" # Wrapped function returns correctly # ID setting for span or trace - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=3) assert len(trace_data.observations) == 3 # trace parameters if set anywhere in the call stack @@ -215,7 +219,7 @@ def level_1_function(*args, **kwargs): assert level_3_observation.name == "level_3" assert level_3_observation.metadata["key"] == mock_deep_metadata["key"] assert level_3_observation.type == "GENERATION" - assert level_3_observation.calculated_total_cost > 0 + assert level_3_observation.total_cost > 0 assert level_3_observation.output == "mock_output" assert level_3_observation.version == "version-1" @@ -260,7 +264,7 @@ def level_1_function(*args, **kwargs): langfuse.flush() - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=3) # trace parameters if set anywhere in the call stack assert trace_data.session_id == mock_session_id @@ -349,7 +353,7 @@ def level_1_function(*args, **kwargs): langfuse.flush() for mock_id in [mock_trace_id_1, mock_trace_id_2]: - trace_data = get_api().trace.get(mock_id) + trace_data = wait_for_trace_snapshot(mock_id, min_observations=3) assert len(trace_data.observations) == 3 # ID setting for span or trace @@ -382,7 +386,7 @@ def level_1_function(*args, **kwargs): assert level_3_observation.metadata["key"] == mock_deep_metadata["key"] assert level_3_observation.type == "GENERATION" - assert level_3_observation.calculated_total_cost > 0 + assert level_3_observation.total_cost > 0 def test_decorators_langchain(): @@ -427,7 +431,7 @@ def level_1_function(*args, **kwargs): langfuse.flush() - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.session_id == mock_session_id @@ -522,8 +526,10 @@ def level_1_function(*args, **kwargs): assert result == "level_3" # Wrapped function returns correctly # ID setting for span or trace - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, + min_observations=3, + min_scores=3, is_result_ready=lambda trace: { "test-observation-score", "test-trace-score", @@ -553,7 +559,7 @@ def level_1_function(*args, **kwargs): assert any( [ score.name == "another-test-trace-score" - and score.string_value == "my_value" + and score.value == "my_value" and score.data_type == "CATEGORICAL" for score in trace_scores ] @@ -599,7 +605,7 @@ def function_with_circular_arg(circular_obj, *args, **kwargs): # Validate that the function executed as expected assert result == "function response" - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=1) assert ( trace_data.observations[0].input["args"][0]["reference"] == "CircularRefObject" @@ -632,7 +638,7 @@ def main(*args, **kwargs): assert result == "function response" - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) # Check that disabled capture_io doesn't capture manually set input/output assert len(trace_data.observations) == 2 @@ -702,7 +708,7 @@ def level_1_function(self, *args, **kwargs): assert result == "level_1" # Wrapped function returns correctly # ID setting for span or trace - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=4) assert len(trace_data.observations) == 4 # trace parameters if set anywhere in the call stack @@ -743,7 +749,7 @@ def level_1_function(self, *args, **kwargs): assert level_3_observation.name == "level_3_function" assert level_3_observation.metadata["key"] == mock_deep_metadata["key"] assert level_3_observation.type == "GENERATION" - assert level_3_observation.calculated_total_cost > 0 + assert level_3_observation.total_cost > 0 assert level_3_observation.output == "mock_output" @@ -779,7 +785,7 @@ def main(**kwargs): assert result == mock_output - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) # Find the main and nested observations adjacencies = defaultdict(list) @@ -833,7 +839,7 @@ async def main_async(**kwargs): assert result == mock_output - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) # Check correct nesting adjacencies = defaultdict(list) @@ -902,7 +908,7 @@ async def level_1_function(*args, **kwargs): assert result == "level_1" # Wrapped function returns correctly # ID setting for span or trace - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.session_id == mock_session_id @@ -946,11 +952,11 @@ async def level_1_function(*args, **kwargs): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.usage.input is not None - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["input"] is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None print(generation) - assert generation.output == 2 + assert generation.output == "2" def test_generator_as_function_input(): @@ -982,7 +988,7 @@ def main(**kwargs): assert result == mock_output - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) nested_obs = next(o for o in trace_data.observations if o.name == "nested") @@ -1019,7 +1025,7 @@ def main(**kwargs): main(langfuse_trace_id=mock_trace_id) langfuse.flush() - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) # Find the observation with name 'nested' nested_observation = next(o for o in trace_data.observations if o.name == "nested") @@ -1052,7 +1058,7 @@ def function(): assert result == mock_output - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: any( observation.name == "function" and observation.output == mock_output @@ -1071,10 +1077,10 @@ def test_media(): media = LangfuseMedia(content_bytes=pdf_bytes, content_type="application/pdf") - @observe() + @observe(capture_input=False, capture_output=False) def main(): sleep(1) - langfuse.set_current_trace_io( + langfuse.update_current_span( input={ "context": { "nested": media, @@ -1099,7 +1105,7 @@ def main(): langfuse.flush() - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( "@@@langfuseMedia:type=application/pdf|id=" @@ -1155,19 +1161,20 @@ def main(): langfuse.flush() - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, - is_result_ready=lambda trace: ( - trace.metadata is not None - and trace.metadata.get("key1") == "value1" - and trace.metadata.get("key2") == "value2" - and trace.tags == ["tag1", "tag2"] - ), + min_observations=2, + is_result_ready=lambda trace: trace.tags == ["tag1", "tag2"], ) - assert trace_data.metadata["key1"] == "value1" - assert trace_data.metadata["key2"] == "value2" + # Trace metadata comes from the root; the nested propagation only reaches + # the nested observation. + assert trace_data.metadata == {"key1": "value1"} + nested_observation = _get_observation_by_name(trace_data, "nested") + assert user_metadata(nested_observation) == {"key1": "value1", "key2": "value2"} + assert nested_observation.tags == ["tag1", "tag2"] + assert trace_data.root.tags == ["tag1"] assert trace_data.tags == ["tag1", "tag2"] @@ -1227,7 +1234,7 @@ def level_1_function(*args, **kwargs): assert result == "level_1" # Verify trace was created properly - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.name == mock_name and len(trace.observations) == 3 @@ -1287,7 +1294,7 @@ def level_1_function(*args, **kwargs): assert result == "level_4" - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.name == mock_name @@ -1362,7 +1369,7 @@ def level_1_function(*args, **kwargs): assert result == "level_1" - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.name == mock_name and len(trace.observations) == 2 @@ -1417,13 +1424,7 @@ def level_1_function(*args, **kwargs): # Should skip tracing entirely in multi-project setup without public key # This is expected behavior to prevent cross-project data leakage - try: - trace_data = get_api().trace.get(mock_trace_id) - # If trace is found, it should have no observations (tracing was skipped) - assert len(trace_data.observations) == 0 - except Exception: - # Trace not found is also expected - tracing was completely disabled - pass + assert get_observations(trace_id=mock_trace_id) == [] # Reset instances to not leak to other test suites removeMockResourceManagerInstances() @@ -1486,7 +1487,7 @@ async def async_level_1_function(*args, **kwargs): assert result == "async_level_3" # Verify trace was created properly - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( trace.name == mock_name @@ -1568,7 +1569,7 @@ async def async_level_1_function(*args, **kwargs): assert result == "sync_level_4" - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=4) assert len(trace_data.observations) == 4 assert trace_data.name == mock_name @@ -1647,8 +1648,8 @@ async def async_level_1_function(task_id, *args, **kwargs): assert result2 == "async_level_3_task_2" # Verify both traces were created correctly and didn't interfere - trace_data_1 = get_api().trace.get(trace_id_1) - trace_data_2 = get_api().trace.get(trace_id_2) + trace_data_1 = wait_for_trace_snapshot(trace_id_1, min_observations=3) + trace_data_2 = wait_for_trace_snapshot(trace_id_2, min_observations=3) assert trace_data_1.name == f"{mock_name}_task_1" assert trace_data_2.name == f"{mock_name}_task_2" @@ -1721,7 +1722,7 @@ async def async_consumer_function(): assert result == "Hello, Async World!" - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) assert len(trace_data.observations) == 2 assert trace_data.name == mock_name @@ -1786,7 +1787,7 @@ async def async_root_function(*args, **kwargs): assert result == "exception_handled" - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=3) assert len(trace_data.observations) == 3 assert trace_data.name == mock_name @@ -1851,11 +1852,11 @@ def root_function(): ) # Verify trace structure - trace_data = wait_for_trace( + trace_data = wait_for_trace_snapshot( mock_trace_id, is_result_ready=lambda trace: ( len(trace.observations) >= 2 - and {"parent_root", "child_stream"}.issubset( + and {"root", "sync_generator"}.issubset( { observation.name for observation in trace.observations @@ -1928,7 +1929,7 @@ async def root_function(): ) # Verify trace structure - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) assert len(trace_data.observations) == 2 # Verify both observations are present @@ -1996,7 +1997,7 @@ async def parent_function(): ) # Verify trace structure - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) assert len(trace_data.observations) == 2 # Check both observations exist @@ -2043,7 +2044,7 @@ async def root_function(): assert items == ["first_item"] # Verify trace structure - should have both observations despite exception - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) assert len(trace_data.observations) == 2 # Check that the failing generator observation has ERROR level @@ -2083,7 +2084,7 @@ def root_function(): assert items == [] # Verify trace structure - trace_data = get_api().trace.get(mock_trace_id) + trace_data = wait_for_trace_snapshot(mock_trace_id, min_observations=2) assert len(trace_data.observations) == 2 # Verify empty generator observation diff --git a/tests/e2e/test_media.py b/tests/e2e/test_media.py index d322e1788..ad8a8cb37 100644 --- a/tests/e2e/test_media.py +++ b/tests/e2e/test_media.py @@ -4,7 +4,7 @@ from langfuse._client.client import Langfuse from langfuse.media import LangfuseMedia -from tests.support.utils import wait_for_trace +from tests.support.utils import wait_for_observations def test_replace_media_reference_string_in_object(): @@ -30,25 +30,26 @@ def test_replace_media_reference_string_in_object(): langfuse.flush() - fetched_trace = wait_for_trace( + fetched_observations = wait_for_observations( span.trace_id, - is_result_ready=lambda trace: ( - bool(trace.observations) - and re.match( + is_result_ready=lambda observations: ( + re.match( r"^@@@langfuseMedia:type=audio/wav\|id=.+\|source=base64_data_uri@@@$", - trace.observations[0].metadata.get("context", {}).get("nested", ""), + observations[0].metadata.get("context", {}).get("nested", ""), ) is not None ), ) - media_ref = fetched_trace.observations[0].metadata["context"]["nested"] + assert len(fetched_observations) == 1 + fetched_observation = fetched_observations[0] + media_ref = fetched_observation.metadata["context"]["nested"] assert re.match( r"^@@@langfuseMedia:type=audio/wav\|id=.+\|source=base64_data_uri@@@$", media_ref, ) resolved_obs = langfuse.resolve_media_references( - obj=fetched_trace.observations[0], resolve_with="base64_data_uri" + obj=fetched_observation, resolve_with="base64_data_uri" ) expected_base64 = f"data:audio/wav;base64,{base64_audio}" @@ -61,15 +62,11 @@ def test_replace_media_reference_string_in_object(): langfuse.flush() - fetched_trace2 = wait_for_trace( + fetched_observations2 = wait_for_observations( span2.trace_id, - is_result_ready=lambda trace: ( - bool(trace.observations) - and trace.observations[0].metadata.get("context", {}).get("nested") - == fetched_trace.observations[0].metadata["context"]["nested"] + is_result_ready=lambda observations: ( + observations[0].metadata.get("context", {}).get("nested") == media_ref ), ) - assert ( - fetched_trace2.observations[0].metadata["context"]["nested"] - == fetched_trace.observations[0].metadata["context"]["nested"] - ) + assert len(fetched_observations2) == 1 + assert fetched_observations2[0].metadata["context"]["nested"] == media_ref diff --git a/tests/e2e/test_prompt.py b/tests/e2e/test_prompt.py index 6e113cb41..5b611d1fb 100644 --- a/tests/e2e/test_prompt.py +++ b/tests/e2e/test_prompt.py @@ -1,7 +1,7 @@ import pytest from langfuse._client.client import Langfuse -from tests.support.utils import create_uuid, get_api +from tests.support.utils import create_uuid, wait_for_observations def test_create_prompt(): @@ -429,18 +429,17 @@ def test_prompt_end_to_end(): langfuse.flush() - api = get_api() - - trace = api.trace.get(generation.trace_id) - - assert len(trace.observations) == 1 - - generation = trace.observations[0] - assert generation.prompt_id is not None + observations = wait_for_observations( + generation.trace_id, + is_result_ready=lambda observations: observations[0].prompt_id is not None, + ) - observation = api.legacy.observations_v1.get(generation.id) + assert len(observations) == 1 + observation = observations[0] assert observation.prompt_id is not None + assert observation.prompt_name == "test" + assert observation.prompt_version == prompt.version def test_do_not_return_fallback_if_fetch_success(): @@ -525,11 +524,10 @@ def test_do_not_link_observation_if_fallback(): ).end() langfuse.flush() - api = get_api() - trace = api.trace.get(generation.trace_id) + observations = wait_for_observations(generation.trace_id) - assert len(trace.observations) == 1 - assert trace.observations[0].prompt_id is None + assert len(observations) == 1 + assert observations[0].prompt_id is None def test_variable_names_on_content_with_variable_names(): diff --git a/tests/live_provider/test_langchain.py b/tests/live_provider/test_langchain.py index d04ddb81c..c1ebe8566 100644 --- a/tests/live_provider/test_langchain.py +++ b/tests/live_provider/test_langchain.py @@ -1,3 +1,4 @@ +import json import random import string import time @@ -18,7 +19,12 @@ from langfuse._client.client import Langfuse from langfuse.langchain import CallbackHandler -from tests.support.utils import create_uuid, encode_file_to_base64, get_api +from tests.support.utils import ( + create_uuid, + encode_file_to_base64, + wait_for_observations, + wait_for_trace_snapshot, +) def test_callback_generated_from_trace_chat(): @@ -44,7 +50,7 @@ def test_callback_generated_from_trace_chat(): langfuse.flush() - trace = get_api().trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) assert trace.input is None assert trace.output is None @@ -92,7 +98,7 @@ def test_callback_generated_from_lcel_chain(): langfuse.flush() - trace = get_api().trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) assert trace.input is None assert trace.output is None @@ -151,10 +157,10 @@ def test_basic_chat_openai(): # Ensure data is flushed to API sleep(2) - # Retrieve trace by name - traces = get_api().trace.list(name=test_name) - assert len(traces.data) > 0 - trace = get_api().trace.get(traces.data[0].id) + # Retrieve trace by its root observation, which carries the run name + roots = wait_for_observations(name=test_name) + assert len(roots) > 0 + trace = wait_for_trace_snapshot(roots[0].trace_id, min_observations=2) # Assertions assert trace.name == test_name @@ -193,7 +199,7 @@ def test_callback_simple_openai(): sleep(2) # Retrieve trace - trace = get_api().trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) # Assertions - add 1 for the wrapping span assert len(trace.observations) > 1 @@ -241,7 +247,7 @@ def test_callback_multiple_invocations_on_different_traces(): sleep(2) # Retrieve trace - trace = get_api().trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=3) # Add 1 to account for the wrapping span assert len(trace.observations) > 2 @@ -300,7 +306,7 @@ def test_openai_instruct_usage(): lf_handler._langfuse_client.flush() - observations = get_api().trace.get(trace_id).observations + observations = wait_for_observations(trace_id, min_count=3) assert len(observations) >= 3 assert any( @@ -318,7 +324,7 @@ def test_openai_instruct_usage(): assert observation.output != "" assert observation.input is not None assert observation.input != "" - assert observation.usage is not None + assert observation.usage_details is not None assert observation.usage_details["input"] is not None assert observation.usage_details["output"] is not None assert observation.usage_details["total"] is not None @@ -479,7 +485,7 @@ def test_link_langfuse_prompts_invoke(): langfuse_handler._langfuse_client.flush() sleep(2) - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=4) observations = trace.observations @@ -567,7 +573,12 @@ def test_link_langfuse_prompts_stream(): langfuse_handler._langfuse_client.flush() sleep(2) - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot( + trace_id, + is_result_ready=lambda trace: ( + len([o for o in trace.observations if o.type == "GENERATION"]) >= 4 + ), + ) observations = trace.observations @@ -653,11 +664,26 @@ def test_link_langfuse_prompts_batch(): langfuse_handler._langfuse_client.flush() - traces = get_api().trace.list(name=trace_name).data + trace_name_filter = json.dumps( + [ + { + "type": "string", + "column": "traceName", + "operator": "=", + "value": trace_name, + } + ] + ) + traced_observations = wait_for_observations(filter=trace_name_filter) - assert len(traces) == 1 + assert {o.trace_id for o in traced_observations} == {trace_id} - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot( + trace_id, + is_result_ready=lambda trace: ( + len([o for o in trace.observations if o.type == "GENERATION"]) >= 10 + ), + ) observations = trace.observations @@ -783,7 +809,7 @@ class GetWeather(BaseModel): handler._langfuse_client.flush() - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generations = list(filter(lambda x: x.type == "GENERATION", trace.observations)) assert len(generations) > 0 @@ -882,7 +908,7 @@ def test_multimodal(): handler._langfuse_client.flush() - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) assert len(trace.observations) >= 2 assert any( @@ -979,7 +1005,7 @@ def call_model(state: MessagesState): print(final_state["messages"][-1].content) handler._langfuse_client.flush() - trace = get_api().trace.get(trace_id=trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) assert len(trace.observations) > 0 @@ -1012,7 +1038,7 @@ def test_cached_token_usage(): handler._langfuse_client.flush() - trace = get_api().trace.get(handler.get_trace_id()) + trace = wait_for_trace_snapshot(handler.get_trace_id()) generation = next((o for o in trace.observations if o.type == "GENERATION")) diff --git a/tests/live_provider/test_langchain_integration.py b/tests/live_provider/test_langchain_integration.py index edb5455c4..dd6b166b2 100644 --- a/tests/live_provider/test_langchain_integration.py +++ b/tests/live_provider/test_langchain_integration.py @@ -7,7 +7,7 @@ from langfuse import Langfuse from langfuse.langchain import CallbackHandler -from tests.support.utils import create_uuid, get_api +from tests.support.utils import create_uuid, wait_for_trace_snapshot def _is_streaming_response(response): @@ -45,8 +45,7 @@ def test_stream_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -61,15 +60,15 @@ def test_stream_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -100,8 +99,7 @@ def test_stream_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -116,14 +114,14 @@ def test_stream_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -150,8 +148,7 @@ def test_invoke_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -165,15 +162,15 @@ def test_invoke_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -201,8 +198,7 @@ def test_invoke_in_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -216,14 +212,14 @@ def test_invoke_in_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert test_phrase in generation.output assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -251,8 +247,7 @@ def test_batch_in_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=3) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -266,13 +261,13 @@ def test_batch_in_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -300,8 +295,7 @@ def test_batch_in_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=3) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -314,13 +308,13 @@ def test_batch_in_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -354,8 +348,7 @@ async def test_astream_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -369,15 +362,15 @@ async def test_astream_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -411,8 +404,7 @@ async def test_astream_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -427,14 +419,14 @@ async def test_astream_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert test_phrase in generation.output assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -463,8 +455,7 @@ async def test_ainvoke_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -478,15 +469,15 @@ async def test_ainvoke_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -514,8 +505,7 @@ async def test_ainvoke_in_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=2) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -529,14 +519,14 @@ async def test_ainvoke_in_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert generation.metadata["tags"] == tags - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert test_phrase in generation.output assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -571,8 +561,7 @@ def test_chains_batch_in_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=9) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -585,13 +574,13 @@ def test_chains_batch_in_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -622,8 +611,7 @@ def test_chains_batch_in_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=9) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -636,13 +624,13 @@ def test_chains_batch_in_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -675,8 +663,7 @@ async def test_chains_abatch_in_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=9) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -689,13 +676,13 @@ async def test_chains_abatch_in_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -725,8 +712,7 @@ async def test_chains_abatch_in_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=9) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 assert len(trace.observations) == 9 @@ -738,13 +724,13 @@ async def test_chains_abatch_in_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -778,8 +764,7 @@ async def test_chains_ainvoke_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=5) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -792,15 +777,15 @@ async def test_chains_ainvoke_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -834,8 +819,7 @@ async def test_chains_ainvoke_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=5) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -848,13 +832,13 @@ async def test_chains_ainvoke_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -894,8 +878,7 @@ async def test_chains_astream_chat_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=5) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -910,15 +893,15 @@ async def test_chains_astream_chat_models(model_name): assert generation.model_parameters.get("max_completion_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.output["content"] is not None assert generation.output["role"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None @@ -956,8 +939,7 @@ async def test_chains_astream_completions_models(model_name): langfuse_client.flush() assert handler.runs == {} - api = get_api() - trace = api.trace.get(trace_id) + trace = wait_for_trace_snapshot(trace_id, min_observations=5) generationList = list(filter(lambda o: o.type == "GENERATION", trace.observations)) assert len(generationList) != 0 @@ -972,11 +954,11 @@ async def test_chains_astream_completions_models(model_name): assert generation.model_parameters.get("max_tokens") is not None assert generation.model_parameters.get("temperature") is not None assert all(x in generation.metadata["tags"] for x in tags) - assert generation.usage.output is not None - assert generation.usage.total is not None + assert generation.usage_details["output"] is not None + assert generation.usage_details["total"] is not None assert generation.input_price is not None assert generation.output_price is not None - assert generation.calculated_input_cost is not None - assert generation.calculated_output_cost is not None - assert generation.calculated_total_cost is not None + assert generation.cost_details["input"] is not None + assert generation.cost_details["output"] is not None + assert generation.total_cost is not None assert generation.latency is not None diff --git a/tests/live_provider/test_openai.py b/tests/live_provider/test_openai.py index 43a07be23..18dc61b81 100644 --- a/tests/live_provider/test_openai.py +++ b/tests/live_provider/test_openai.py @@ -6,7 +6,11 @@ from pydantic import BaseModel from langfuse._client.client import Langfuse -from tests.support.utils import create_uuid, encode_file_to_base64, get_api +from tests.support.utils import ( + create_uuid, + encode_file_to_base64, + wait_for_observations, +) langfuse: Langfuse | None = None @@ -58,27 +62,25 @@ def test_openai_chat_completion(openai): sleep(1) - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" assert len(completion.choices) != 0 - assert generation.data[0].input == [ + assert generation[0].input == [ { "content": "You are an expert mathematician", "role": "assistant", }, {"content": "1 + 1 = ", "role": "user"}, ] - assert generation.data[0].type == "GENERATION" - assert "gpt-3.5-turbo-0125" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].type == "GENERATION" + assert "gpt-3.5-turbo-0125" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 0, "top_p": 1, @@ -86,11 +88,11 @@ def test_openai_chat_completion(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert "2" in generation.data[0].output["content"] - assert generation.data[0].output["role"] == "assistant" + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert "2" in generation[0].output["content"] + assert generation[0].output["role"] == "assistant" def test_openai_chat_completion_stream(openai): @@ -116,21 +118,19 @@ def test_openai_chat_completion_stream(openai): langfuse.flush() sleep(3) - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert "gpt-3.5-turbo-0125" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert "gpt-3.5-turbo-0125" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 0, "top_p": 1, @@ -138,16 +138,16 @@ def test_openai_chat_completion_stream(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].output == 2 - assert generation.data[0].completion_start_time is not None + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert generation[0].output == "2" + assert generation[0].completion_start_time is not None # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time def test_openai_chat_completion_stream_with_next_iteration(openai): @@ -177,21 +177,19 @@ def test_openai_chat_completion_stream_with_next_iteration(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo-0125" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo-0125" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 0, "top_p": 1, @@ -199,16 +197,16 @@ def test_openai_chat_completion_stream_with_next_iteration(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].output == 2 - assert generation.data[0].completion_start_time is not None + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert generation[0].output == "2" + assert generation[0].completion_start_time is not None # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time def test_openai_chat_completion_stream_fail(openai): @@ -227,33 +225,28 @@ def test_openai_chat_completion_stream_fail(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "fake" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" + + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "fake" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].level == "ERROR" - assert generation.data[0].status_message is not None - assert generation.data[0].output is None + assert generation[0].level == "ERROR" + assert generation[0].status_message is not None + assert generation[0].output is None openai.api_key = os.environ["OPENAI_API_KEY"] @@ -277,13 +270,11 @@ def test_openai_chat_completion_with_langfuse_prompt(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert isinstance(generation.data[0].prompt_id, str) + assert len(generation) != 0 + assert generation[0].name == generation_name + assert isinstance(generation[0].prompt_id, str) def test_openai_chat_completion_fail(openai): @@ -300,29 +291,27 @@ def test_openai_chat_completion_fail(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) - - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "fake" - assert generation.data[0].level == "ERROR" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].status_message is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "fake" + assert generation[0].level == "ERROR" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].status_message is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].output is None + assert generation[0].output is None openai.api_key = os.environ["OPENAI_API_KEY"] @@ -360,25 +349,21 @@ def test_openai_chat_completion_two_calls(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name + assert len(generation) != 0 + assert generation[0].name == generation_name assert len(completion.choices) != 0 - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] - generation_2 = get_api().legacy.observations_v1.get_many( - name=generation_name_2, type="GENERATION" - ) + generation_2 = wait_for_observations(name=generation_name_2, type="GENERATION") - assert len(generation_2.data) != 0 - assert generation_2.data[0].name == generation_name_2 + assert len(generation_2) != 0 + assert generation_2[0].name == generation_name_2 assert len(completion_2.choices) != 0 - assert generation_2.data[0].input == [{"content": "2 + 2 = ", "role": "user"}] + assert generation_2[0].input == [{"content": "2 + 2 = ", "role": "user"}] def test_openai_chat_completion_with_seed(openai): @@ -394,11 +379,9 @@ def test_openai_chat_completion_with_seed(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert generation.data[0].model_parameters == { + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 0, "top_p": 1, @@ -423,32 +406,30 @@ def test_openai_completion(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" assert len(completion.choices) != 0 - assert completion.choices[0].text == generation.data[0].output - assert generation.data[0].input == "1 + 1 = " - assert generation.data[0].type == "GENERATION" - assert "gpt-3.5-turbo-instruct" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert completion.choices[0].text == generation[0].output + assert generation[0].input == "1 + 1 = " + assert generation[0].type == "GENERATION" + assert "gpt-3.5-turbo-instruct" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].output == "2\n\n1 + 2 = 3\n\n2 + 3 = " + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert generation[0].output == "2\n\n1 + 2 = 3\n\n2 + 3 = " @requires_legacy_completion_model @@ -472,37 +453,35 @@ def test_openai_completion_stream(openai): assert len(content) > 0 - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - - assert generation.data[0].input == "1 + 1 = " - assert generation.data[0].type == "GENERATION" - assert "gpt-3.5-turbo-instruct" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" + + assert generation[0].input == "1 + 1 = " + assert generation[0].type == "GENERATION" + assert "gpt-3.5-turbo-instruct" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].output == "2\n\n1 + 2 = 3\n\n2 + 3 = " - assert generation.data[0].completion_start_time is not None + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert generation[0].output == "2\n\n1 + 2 = 3\n\n2 + 3 = " + assert generation[0].completion_start_time is not None # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time def test_openai_completion_fail(openai): @@ -521,29 +500,27 @@ def test_openai_completion_fail(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) - - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - assert generation.data[0].input == "1 + 1 = " - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "fake" - assert generation.data[0].level == "ERROR" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].status_message is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" + assert generation[0].input == "1 + 1 = " + assert generation[0].type == "GENERATION" + assert generation[0].model == "fake" + assert generation[0].level == "ERROR" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].status_message is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].output is None + assert generation[0].output is None openai.api_key = os.environ["OPENAI_API_KEY"] @@ -564,33 +541,28 @@ def test_openai_completion_stream_fail(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - - assert generation.data[0].input == "1 + 1 = " - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].input == "1 + 1 = " + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "temperature": 0, "top_p": 1, "frequency_penalty": 0, "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].level == "ERROR" - assert generation.data[0].status_message is not None - assert generation.data[0].output is None + assert generation[0].level == "ERROR" + assert generation[0].status_message is not None + assert generation[0].output is None openai.api_key = os.environ["OPENAI_API_KEY"] @@ -614,13 +586,11 @@ def test_openai_completion_with_langfuse_prompt(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert isinstance(generation.data[0].prompt_id, str) + assert len(generation) != 0 + assert generation[0].name == generation_name + assert isinstance(generation[0].prompt_id, str) def test_fails_wrong_name(openai): @@ -656,21 +626,19 @@ async def test_async_chat(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name + assert len(generation) != 0 + assert generation[0].name == generation_name assert len(completion.choices) != 0 - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo-0125" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo-0125" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 1, "top_p": 1, @@ -678,11 +646,11 @@ async def test_async_chat(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert "2" in generation.data[0].output["content"] - assert generation.data[0].output["role"] == "assistant" + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert "2" in generation[0].output["content"] + assert generation[0].output["role"] == "assistant" @pytest.mark.asyncio @@ -703,19 +671,17 @@ async def test_async_chat_stream(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) - - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo-0125" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo-0125" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 1, "top_p": 1, @@ -723,15 +689,15 @@ async def test_async_chat_stream(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert "2" in str(generation.data[0].output) + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert "2" in str(generation[0].output) # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time @pytest.mark.asyncio @@ -762,21 +728,19 @@ async def test_async_chat_stream_with_anext(openai): print(result) - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].input == [ + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].input == [ {"content": "Give me a one-liner joke", "role": "user"} ] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo-0125" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo-0125" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 1, "top_p": 1, @@ -784,14 +748,14 @@ async def test_async_chat_stream_with_anext(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time def test_openai_function_call(openai): @@ -825,14 +789,12 @@ class StepByStepAIResponse(BaseModel): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].output is not None - assert "function_call" in generation.data[0].output + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].output is not None + assert "function_call" in generation[0].output assert output["title"] is not None @@ -869,14 +831,12 @@ class StepByStepAIResponse(BaseModel): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].output is not None - assert "function_call" in generation.data[0].output + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].output is not None + assert "function_call" in generation[0].output def test_openai_tool_call(openai): @@ -913,21 +873,17 @@ def test_openai_tool_call(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name + assert len(generation) != 0 + assert generation[0].name == generation_name assert ( - generation.data[0].output["tool_calls"][0]["function"]["name"] + generation[0].output["tool_calls"][0]["function"]["name"] == "get_current_weather" ) - assert ( - generation.data[0].output["tool_calls"][0]["function"]["arguments"] is not None - ) - assert generation.data[0].input["tools"] == tools - assert generation.data[0].input["messages"] == messages + assert generation[0].output["tool_calls"][0]["function"]["arguments"] is not None + assert generation[0].input["tools"] == tools + assert generation[0].input["messages"] == messages def test_openai_tool_call_streamed(openai): @@ -969,22 +925,18 @@ def test_openai_tool_call_streamed(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name + assert len(generation) != 0 + assert generation[0].name == generation_name assert ( - generation.data[0].output["tool_calls"][0]["function"]["name"] + generation[0].output["tool_calls"][0]["function"]["name"] == "get_current_weather" ) - assert ( - generation.data[0].output["tool_calls"][0]["function"]["arguments"] is not None - ) - assert generation.data[0].input["tools"] == tools - assert generation.data[0].input["messages"] == messages + assert generation[0].output["tool_calls"][0]["function"]["arguments"] is not None + assert generation[0].input["tools"] == tools + assert generation[0].input["messages"] == messages def test_langchain_integration(openai): @@ -1047,28 +999,28 @@ def test_structured_output_response_format_kwarg(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" + generation = wait_for_observations( + name=generation_name, type="GENERATION", expand_metadata="response_format" ) - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].metadata["someKey"] == "someResponse" - assert generation.data[0].metadata["response_format"] == { + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].metadata["someKey"] == "someResponse" + assert generation[0].metadata["response_format"] == { "type": "json_schema", "json_schema": json_schema, } - assert generation.data[0].input == [ + assert generation[0].input == [ {"role": "system", "content": "You are a helpful math tutor."}, {"content": "solve 8x + 31 = 2", "role": "user"}, ] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-4o-2024-08-06" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-4o-2024-08-06" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 1, "top_p": 1, @@ -1076,10 +1028,10 @@ def test_structured_output_response_format_kwarg(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert generation.data[0].output["role"] == "assistant" + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert generation[0].output["role"] == "assistant" def test_structured_output_beta_completions_parse(openai): @@ -1117,31 +1069,29 @@ class CalendarEvent(BaseModel): if Version(openai.__version__) >= Version("1.50.0"): # Check the trace and observation properties - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) == 1 - assert generation.data[0].name == generation_name - assert generation.data[0].type == "GENERATION" - assert "gpt-4o" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time + assert len(generation) == 1 + assert generation[0].name == generation_name + assert generation[0].type == "GENERATION" + assert "gpt-4o" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time # Check input and output - assert len(generation.data[0].input) == 2 - assert generation.data[0].input[0]["role"] == "system" - assert generation.data[0].input[1]["role"] == "user" - assert isinstance(generation.data[0].output, dict) - assert "name" in generation.data[0].output["content"] - assert "date" in generation.data[0].output["content"] - assert "participants" in generation.data[0].output["content"] + assert len(generation[0].input) == 2 + assert generation[0].input[0]["role"] == "system" + assert generation[0].input[1]["role"] == "user" + assert isinstance(generation[0].output, dict) + assert "name" in generation[0].output["content"] + assert "date" in generation[0].output["content"] + assert "participants" in generation[0].output["content"] # Check usage - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None @pytest.mark.asyncio @@ -1163,19 +1113,17 @@ async def test_close_async_stream(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) - - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].input == [{"content": "1 + 1 = ", "role": "user"}] - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == "gpt-3.5-turbo-0125" - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].model_parameters == { + generation = wait_for_observations(name=generation_name, type="GENERATION") + + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].input == [{"content": "1 + 1 = ", "role": "user"}] + assert generation[0].type == "GENERATION" + assert generation[0].model == "gpt-3.5-turbo-0125" + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].model_parameters == { "service_tier": "default", "temperature": 1, "top_p": 1, @@ -1183,15 +1131,15 @@ async def test_close_async_stream(openai): "max_tokens": "Infinity", "presence_penalty": 0, } - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert "2" in str(generation.data[0].output) + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert "2" in str(generation[0].output) # Completion start time for time-to-first-token - assert generation.data[0].completion_start_time is not None - assert generation.data[0].completion_start_time >= generation.data[0].start_time - assert generation.data[0].completion_start_time <= generation.data[0].end_time + assert generation[0].completion_start_time is not None + assert generation[0].completion_start_time >= generation[0].start_time + assert generation[0].completion_start_time <= generation[0].end_time def test_base_64_image_input(openai): @@ -1225,26 +1173,24 @@ def test_base_64_image_input(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name - assert generation.data[0].input[0]["content"][0]["text"] == "What’s in this image?" + assert len(generation) != 0 + assert generation[0].name == generation_name + assert generation[0].input[0]["content"][0]["text"] == "What’s in this image?" assert ( f"@@@langfuseMedia:type={content_type}|id=" - in generation.data[0].input[0]["content"][1]["image_url"]["url"] + in generation[0].input[0]["content"][1]["image_url"]["url"] ) - assert generation.data[0].type == "GENERATION" - assert "gpt-4o-mini" in generation.data[0].model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - assert "dog" in generation.data[0].output["content"] + assert generation[0].type == "GENERATION" + assert "gpt-4o-mini" in generation[0].model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + assert "dog" in generation[0].output["content"] def test_audio_input_and_output(openai): @@ -1277,32 +1223,28 @@ def test_audio_input_and_output(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - assert generation.data[0].name == generation_name + assert len(generation) != 0 + assert generation[0].name == generation_name assert ( - generation.data[0].input[0]["content"][0]["text"] - == "Do what this recording says." + generation[0].input[0]["content"][0]["text"] == "Do what this recording says." ) assert ( "@@@langfuseMedia:type=audio/wav|id=" - in generation.data[0].input[0]["content"][1]["input_audio"]["data"] - ) - assert generation.data[0].type == "GENERATION" - assert generation.data[0].model == model - assert generation.data[0].start_time is not None - assert generation.data[0].end_time is not None - assert generation.data[0].start_time < generation.data[0].end_time - assert generation.data[0].usage.input is not None - assert generation.data[0].usage.output is not None - assert generation.data[0].usage.total is not None - print(generation.data[0].output) + in generation[0].input[0]["content"][1]["input_audio"]["data"] + ) + assert generation[0].type == "GENERATION" + assert generation[0].model == model + assert generation[0].start_time is not None + assert generation[0].end_time is not None + assert generation[0].start_time < generation[0].end_time + assert generation[0].usage_details["input"] is not None + assert generation[0].usage_details["output"] is not None + assert generation[0].usage_details["total"] is not None + print(generation[0].output) assert ( - "@@@langfuseMedia:type=audio/wav|id=" - in generation.data[0].output["audio"]["data"] + "@@@langfuseMedia:type=audio/wav|id=" in generation[0].output["audio"]["data"] ) @@ -1317,25 +1259,22 @@ def test_response_api_text_input(openai): ) langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name assert ( - generation.data[0].input - == "Tell me a three sentence bedtime story about a unicorn." + generation[0].input == "Tell me a three sentence bedtime story about a unicorn." ) assert generationData.type == "GENERATION" assert "gpt-4o" in generationData.model assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None @@ -1363,22 +1302,20 @@ def test_response_api_image_input(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name - assert generation.data[0].input[0]["content"][0]["text"] == "what is in this image?" + assert generation[0].input[0]["content"][0]["text"] == "what is in this image?" assert generationData.type == "GENERATION" assert "gpt-4o" in generationData.model assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None @@ -1395,12 +1332,10 @@ def test_response_api_web_search(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name assert generationData.input == { "input": "What was a positive news story from today?", @@ -1411,9 +1346,9 @@ def test_response_api_web_search(openai): assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None assert generationData.metadata is not None @@ -1435,14 +1370,12 @@ def test_response_api_streaming(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name - assert generation.data[0].input == [ + assert generation[0].input == [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello!"}, ] @@ -1451,9 +1384,9 @@ def test_response_api_streaming(openai): assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None assert generationData.metadata is not None assert generationData.metadata["instructions"] == "You are a helpful assistant." @@ -1492,14 +1425,12 @@ def test_response_api_functions(openai): langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name - assert generation.data[0].input == { + assert generation[0].input == { "input": "What is the weather like in Boston today?", "tools": tools, "tool_choice": "auto", @@ -1509,9 +1440,9 @@ def test_response_api_functions(openai): assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None assert generationData.metadata is not None @@ -1528,22 +1459,20 @@ def test_response_api_reasoning(openai): ) langfuse.flush() - generation = get_api().legacy.observations_v1.get_many( - name=generation_name, type="GENERATION" - ) + generation = wait_for_observations(name=generation_name, type="GENERATION") - assert len(generation.data) != 0 - generationData = generation.data[0] + assert len(generation) != 0 + generationData = generation[0] assert generationData.name == generation_name - assert generation.data[0].input == "How much wood would a woodchuck chuck?" + assert generation[0].input == "How much wood would a woodchuck chuck?" assert generationData.type == "GENERATION" assert "o3-mini" in generationData.model assert generationData.start_time is not None assert generationData.end_time is not None assert generationData.start_time < generationData.end_time - assert generationData.usage.input is not None - assert generationData.usage.output is not None - assert generationData.usage.total is not None + assert generationData.usage_details["input"] is not None + assert generationData.usage_details["output"] is not None + assert generationData.usage_details["total"] is not None assert generationData.output is not None assert generationData.metadata is not None @@ -1560,12 +1489,10 @@ def test_openai_embeddings(openai): langfuse.flush() sleep(1) - embedding = get_api().legacy.observations_v1.get_many( - name=embedding_name, type="EMBEDDING" - ) + embedding = wait_for_observations(name=embedding_name, type="EMBEDDING") - assert len(embedding.data) != 0 - embedding_data = embedding.data[0] + assert len(embedding) != 0 + embedding_data = embedding[0] assert embedding_data.name == embedding_name assert embedding_data.metadata["test_key"] == "test_value" assert embedding_data.input == "The quick brown fox jumps over the lazy dog" @@ -1574,8 +1501,8 @@ def test_openai_embeddings(openai): assert embedding_data.start_time is not None assert embedding_data.end_time is not None assert embedding_data.start_time < embedding_data.end_time - assert embedding_data.usage.input is not None - assert embedding_data.usage.total is not None + assert embedding_data.usage_details["input"] is not None + assert embedding_data.usage_details["total"] is not None assert embedding_data.output is not None assert "dimensions" in embedding_data.output assert "count" in embedding_data.output @@ -1596,18 +1523,16 @@ def test_openai_embeddings_multiple_inputs(openai): langfuse.flush() sleep(1) - embedding = get_api().legacy.observations_v1.get_many( - name=embedding_name, type="EMBEDDING" - ) + embedding = wait_for_observations(name=embedding_name, type="EMBEDDING") - assert len(embedding.data) != 0 - embedding_data = embedding.data[0] + assert len(embedding) != 0 + embedding_data = embedding[0] assert embedding_data.name == embedding_name assert embedding_data.input == inputs assert embedding_data.type == "EMBEDDING" assert "text-embedding-ada-002" in embedding_data.model - assert embedding_data.usage.input is not None - assert embedding_data.usage.total is not None + assert embedding_data.usage_details["input"] is not None + assert embedding_data.usage_details["total"] is not None assert embedding_data.output["count"] == len(inputs) @@ -1629,16 +1554,14 @@ async def test_async_openai_embeddings(openai): langfuse.flush() sleep(1) - embedding = get_api().legacy.observations_v1.get_many( - name=embedding_name, type="EMBEDDING" - ) + embedding = wait_for_observations(name=embedding_name, type="EMBEDDING") - assert len(embedding.data) != 0 - embedding_data = embedding.data[0] + assert len(embedding) != 0 + embedding_data = embedding[0] assert embedding_data.name == embedding_name assert embedding_data.input == "Async embedding test" assert embedding_data.type == "EMBEDDING" assert "text-embedding-ada-002" in embedding_data.model assert embedding_data.metadata["async"] is True - assert embedding_data.usage.input is not None - assert embedding_data.usage.total is not None + assert embedding_data.usage_details["input"] is not None + assert embedding_data.usage_details["total"] is not None diff --git a/tests/support/api_wrapper.py b/tests/support/api_wrapper.py deleted file mode 100644 index c4519252f..000000000 --- a/tests/support/api_wrapper.py +++ /dev/null @@ -1,130 +0,0 @@ -import os - -import httpx - -from langfuse.api.commons.errors.not_found_error import NotFoundError -from tests.support.retry import ( - DEFAULT_RETRY_INTERVAL_SECONDS, - DEFAULT_RETRY_TIMEOUT_SECONDS, - is_not_found_payload, - retry_until_ready, -) - - -class LangfuseAPI: - def __init__(self, username=None, password=None, base_url=None): - username = username if username else os.environ["LANGFUSE_PUBLIC_KEY"] - password = password if password else os.environ["LANGFUSE_SECRET_KEY"] - self.auth = (username, password) - self.BASE_URL = base_url if base_url else os.environ["LANGFUSE_BASE_URL"] - - def _get_json( - self, - url, - params=None, - *, - retry=True, - is_result_ready=None, - timeout_seconds=DEFAULT_RETRY_TIMEOUT_SECONDS, - interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, - ): - def _request(): - response = httpx.get(url, params=params, auth=self.auth) - payload = response.json() - - if response.status_code == 404 and is_not_found_payload(payload): - raise NotFoundError(body=payload, headers=dict(response.headers)) - - return payload - - if not retry: - return _request() - - return retry_until_ready( - _request, - is_result_ready=is_result_ready, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - def get_observation( - self, - observation_id, - *, - retry=True, - is_result_ready=None, - timeout_seconds=DEFAULT_RETRY_TIMEOUT_SECONDS, - interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, - ): - url = f"{self.BASE_URL}/api/public/observations/{observation_id}" - return self._get_json( - url, - retry=retry, - is_result_ready=is_result_ready, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - def get_scores( - self, - page=None, - limit=None, - user_id=None, - name=None, - *, - retry=True, - is_result_ready=None, - timeout_seconds=DEFAULT_RETRY_TIMEOUT_SECONDS, - interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, - ): - params = {"page": page, "limit": limit, "userId": user_id, "name": name} - url = f"{self.BASE_URL}/api/public/scores" - return self._get_json( - url, - params=params, - retry=retry, - is_result_ready=is_result_ready, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - def get_traces( - self, - page=None, - limit=None, - user_id=None, - name=None, - *, - retry=True, - is_result_ready=None, - timeout_seconds=DEFAULT_RETRY_TIMEOUT_SECONDS, - interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, - ): - params = {"page": page, "limit": limit, "userId": user_id, "name": name} - url = f"{self.BASE_URL}/api/public/traces" - return self._get_json( - url, - params=params, - retry=retry, - is_result_ready=is_result_ready, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - def get_trace( - self, - trace_id, - *, - retry=True, - is_result_ready=None, - timeout_seconds=DEFAULT_RETRY_TIMEOUT_SECONDS, - interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, - ): - url = f"{self.BASE_URL}/api/public/traces/{trace_id}" - return self._get_json( - url, - retry=retry, - is_result_ready=is_result_ready, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) diff --git a/tests/support/utils.py b/tests/support/utils.py index a29274d3a..191eeae05 100644 --- a/tests/support/utils.py +++ b/tests/support/utils.py @@ -1,19 +1,42 @@ import base64 +import json import os -from typing import Any, Callable, TypeVar +from dataclasses import dataclass +from typing import Any, Callable, Sequence, TypeVar from uuid import uuid4 -from langfuse.api import LangfuseAPI +from langfuse.api import LangfuseAPI, ObservationV2, ScoreV3 from tests.support.retry import ( DEFAULT_RETRY_INTERVAL_SECONDS, DEFAULT_RETRY_TIMEOUT_SECONDS, retry_until_ready, ) -READ_METHOD_NAMES = {"get", "get_by_id", "get_many", "get_run", "list"} -PAGINATION_ARGUMENTS = {"limit", "page"} +READ_METHOD_NAMES = {"get", "get_by_id", "get_many", "get_many_v3", "get_run", "list"} +PAGINATION_ARGUMENTS = {"limit", "page", "cursor", "fields", "expand_metadata"} T = TypeVar("T") +ALL_OBSERVATION_FIELDS = ( + "core,basic,time,io,metadata,model,usage,prompt,metrics,trace_context" +) +SCORE_FIELDS = "details,subject" + +# The v2 observations API returns "" instead of null for unset string fields. +_EMPTY_AS_NONE_FIELDS = ( + "name", + "status_message", + "version", + "user_id", + "session_id", + "model", + "internal_model_id", + "prompt_id", + "prompt_name", + "trace_name", + "release", +) +_SDK_METADATA_KEY_PREFIXES = ("scope.", "resourceAttributes.") + def _has_filters(kwargs: dict[str, Any]) -> bool: return any( @@ -51,7 +74,9 @@ def _call(*args: Any, **kwargs: Any) -> Any: def _result_ready(method_name: str, kwargs: dict[str, Any]): - if method_name not in {"get_many", "list"} or not _has_filters(kwargs): + if method_name not in {"get_many", "get_many_v3", "list"} or not _has_filters( + kwargs + ): return None def _has_data(result: Any) -> bool: @@ -89,17 +114,315 @@ def wait_for_result( ) -def wait_for_trace( +def _parse_json_string(value: Any) -> Any: + # Only objects and arrays: the SDK sends string IO unquoted, so text such as + # "2" or "true" must stay a string. + if not isinstance(value, str) or not value.lstrip().startswith(("{", "[")): + return value + + try: + return json.loads(value) + except ValueError: + return value + + +def normalize_observation(observation: ObservationV2) -> ObservationV2: + """Map a v2 observation to the shape the SDK sent. + + The v2 API returns input/output as raw strings and unset string fields as + "". This parses JSON object/array input/output and maps "" to None. + """ + update: dict[str, Any] = { + "input": _parse_json_string(observation.input), + "output": _parse_json_string(observation.output), + } + for field in _EMPTY_AS_NONE_FIELDS: + if getattr(observation, field, None) == "": + update[field] = None + + return observation.model_copy(update=update) + + +def user_metadata(observation: ObservationV2) -> dict[str, Any]: + """Observation metadata without the scope/resource keys the server adds.""" + metadata = observation.metadata or {} + assert isinstance(metadata, dict), metadata + + return { + key: value + for key, value in metadata.items() + if not key.startswith(_SDK_METADATA_KEY_PREFIXES) + } + + +def get_observations( + *, + fields: str = ALL_OBSERVATION_FIELDS, + api: Any = None, + **filters: Any, +) -> list[ObservationV2]: + """Fetch all observations matching `filters` (all pages), oldest first.""" + api = api or get_api(retry=False) + observations: list[ObservationV2] = [] + cursor = None + + while True: + response = api.observations.get_many( + fields=fields, limit=1000, cursor=cursor, **filters + ) + observations.extend(response.data) + cursor = response.meta.cursor + if not cursor or not response.data: + break + + # The events table can briefly return several rows for one span until + # ClickHouse merges them; keep the most recently updated row per id. + latest_by_id: dict[str, ObservationV2] = {} + for observation in observations: + current = latest_by_id.get(observation.id) + if current is None or (observation.updated_at or observation.start_time) >= ( + current.updated_at or current.start_time + ): + latest_by_id[observation.id] = observation + + return sorted( + (normalize_observation(observation) for observation in latest_by_id.values()), + key=lambda observation: observation.start_time, + ) + + +def wait_for_observations( + trace_id: str | None = None, + *, + min_count: int = 1, + is_result_ready: Callable[[list[ObservationV2]], bool] | None = None, + fields: str = ALL_OBSERVATION_FIELDS, + timeout_seconds: float = DEFAULT_RETRY_TIMEOUT_SECONDS, + interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS, + **filters: Any, +) -> list[ObservationV2]: + """Poll the v2 observations API until at least `min_count` observations + match (and `is_result_ready` holds), then return them oldest first.""" + if trace_id is not None: + filters["trace_id"] = trace_id + assert filters, "wait_for_observations needs at least one filter" + + def _ready(observations: list[ObservationV2]) -> bool: + return len(observations) >= min_count and ( + is_result_ready is None or is_result_ready(observations) + ) + + return wait_for_result( + lambda: get_observations(fields=fields, **filters), + is_result_ready=_ready, + timeout_seconds=timeout_seconds, + interval_seconds=interval_seconds, + ) + + +def get_root_observation(observations: Sequence[ObservationV2]) -> ObservationV2: + """Return the single root observation, which carries the trace-level + attributes (trace name, user, session, tags, public) and trace IO.""" + roots = [ + observation for observation in observations if observation.is_root_observation + ] + assert len(roots) == 1, ( + f"expected exactly one root observation, got {[r.name for r in roots]}" + ) + + return roots[0] + + +def wait_for_root_observation( + trace_id: str, + *, + min_count: int = 1, + is_result_ready: Callable[[ObservationV2], bool] | None = None, + timeout_seconds: float = DEFAULT_RETRY_TIMEOUT_SECONDS, + interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS, +) -> ObservationV2: + def _ready(observations: list[ObservationV2]) -> bool: + roots = [o for o in observations if o.is_root_observation] + return len(roots) == 1 and ( + is_result_ready is None or is_result_ready(roots[0]) + ) + + return get_root_observation( + wait_for_observations( + trace_id, + min_count=min_count, + is_result_ready=_ready, + timeout_seconds=timeout_seconds, + interval_seconds=interval_seconds, + ) + ) + + +@dataclass(frozen=True) +class TraceSnapshot: + """A trace as v4 exposes it: its observations plus (optionally) scores. + + Mirrors how the platform aggregates events into a trace: input, output + and metadata come from the root observation; name, user, session, + version, release and environment are the latest non-empty value across + all observations; tags are the union; public is true if any observation + is public. + """ + + id: str + observations: list[ObservationV2] + scores: list[ScoreV3] | None = None + + @property + def root(self) -> ObservationV2: + return get_root_observation(self.observations) + + def _latest(self, field: str) -> Any: + values = [ + getattr(observation, field) + for observation in self.observations + if getattr(observation, field) + ] + return values[-1] if values else None + + @property + def name(self) -> str | None: + # The API falls back to the root's own name when no trace name was + # set, so a root whose trace_name equals its name carries no signal. + explicit_names = [ + observation.trace_name + for observation in self.observations + if observation.trace_name + and not ( + observation.is_root_observation + and observation.trace_name == observation.name + ) + ] + return explicit_names[-1] if explicit_names else self.root.trace_name + + @property + def user_id(self) -> str | None: + return self._latest("user_id") + + @property + def session_id(self) -> str | None: + return self._latest("session_id") + + @property + def tags(self) -> list[str]: + return sorted( + {tag for observation in self.observations for tag in observation.tags or []} + ) + + @property + def public(self) -> bool: + return any(observation.public for observation in self.observations) + + @property + def version(self) -> str | None: + return self._latest("version") + + @property + def release(self) -> str | None: + return self._latest("release") + + @property + def environment(self) -> str | None: + return self._latest("environment") + + @property + def input(self) -> Any: + return self.root.input + + @property + def output(self) -> Any: + return self.root.output + + @property + def metadata(self) -> dict[str, Any]: + return user_metadata(self.root) + + +def wait_for_trace_snapshot( trace_id: str, *, - is_result_ready: Callable[[Any], bool] | None = None, + min_observations: int = 1, + min_scores: int | None = None, + is_result_ready: Callable[[TraceSnapshot], bool] | None = None, timeout_seconds: float = DEFAULT_RETRY_TIMEOUT_SECONDS, interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS, -): - api = get_api(retry=False) +) -> TraceSnapshot: + """Poll until the trace has `min_observations` observations (and + `min_scores` scores, which are only fetched when given).""" + + def _fetch() -> TraceSnapshot: + return TraceSnapshot( + id=trace_id, + observations=get_observations(trace_id=trace_id), + scores=None if min_scores is None else get_scores(trace_id=trace_id), + ) + + def _ready(snapshot: TraceSnapshot) -> bool: + if len(snapshot.observations) < min_observations: + return False + if min_scores is not None and len(snapshot.scores or []) < min_scores: + return False + if is_result_ready is None: + return True + try: + return is_result_ready(snapshot) + except AssertionError: + # Root-derived attributes are unavailable until the root arrives. + return False + return wait_for_result( - lambda: api.trace.get(trace_id), - is_result_ready=is_result_ready, + _fetch, + is_result_ready=_ready, + timeout_seconds=timeout_seconds, + interval_seconds=interval_seconds, + ) + + +def get_scores( + *, fields: str = SCORE_FIELDS, api: Any = None, **filters: Any +) -> list[ScoreV3]: + api = api or get_api(retry=False) + scores: list[ScoreV3] = [] + cursor = None + + while True: + response = api.scores_v3.get_many_v3( + fields=fields, limit=100, cursor=cursor, **filters + ) + scores.extend(response.data) + cursor = response.meta.cursor + if not cursor or not response.data: + break + + return scores + + +def wait_for_scores( + *, + min_count: int = 1, + is_result_ready: Callable[[list[ScoreV3]], bool] | None = None, + fields: str = SCORE_FIELDS, + timeout_seconds: float = DEFAULT_RETRY_TIMEOUT_SECONDS, + interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS, + **filters: Any, +) -> list[ScoreV3]: + """Poll the v3 scores API (filters: trace_id, session_id, observation_id, + name, ...) until at least `min_count` scores match.""" + assert filters, "wait_for_scores needs at least one filter" + + def _ready(scores: list[ScoreV3]) -> bool: + return len(scores) >= min_count and ( + is_result_ready is None or is_result_ready(scores) + ) + + return wait_for_result( + lambda: get_scores(fields=fields, **filters), + is_result_ready=_ready, timeout_seconds=timeout_seconds, interval_seconds=interval_seconds, ) diff --git a/tests/unit/test_batch_evaluation.py b/tests/unit/test_batch_evaluation.py new file mode 100644 index 000000000..1ac1751fa --- /dev/null +++ b/tests/unit/test_batch_evaluation.py @@ -0,0 +1,380 @@ +"""Unit tests for batch evaluation on the v2 observations API.""" + +import inspect +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any, Dict, List, Optional +from unittest.mock import MagicMock + +import pytest + +from langfuse import Langfuse +from langfuse.api import ObservationsV2Response, ObservationV2 +from langfuse.batch_evaluation import ( + DEFAULT_BATCH_EVALUATION_FIELDS, + BatchEvaluationResumeToken, + BatchEvaluationRunner, + EvaluatorInputs, +) +from langfuse.experiment import Evaluation + +BASE_TIME = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def make_observation( + index: int, + *, + trace_id: Optional[str] = "default", + is_root: bool = False, + start_time: Optional[datetime] = None, + observation_id: Optional[str] = None, + parent_observation_id: Optional[str] = "default", +) -> ObservationV2: + return ObservationV2( + id=observation_id or f"obs-{index}", + trace_id=f"trace-{index}" if trace_id == "default" else trace_id, + start_time=start_time or BASE_TIME + timedelta(seconds=index), + end_time=None, + project_id="project", + parent_observation_id=( + (None if is_root else f"parent-{index}") + if parent_observation_id == "default" + else parent_observation_id + ), + type="SPAN", + is_root_observation=is_root, + input=json.dumps({"question": index}), + output=f"answer {index}", + ) + + +class FakeObservationsApi: + """Serves observations newest first with an index cursor, like the v2 API.""" + + def __init__(self, observations: List[ObservationV2]): + self.observations = sorted( + observations, key=lambda o: (o.start_time, o.id), reverse=True + ) + self.calls: List[Dict[str, Any]] = [] + self.fail_on_call: Optional[int] = None + + def get_many(self, **kwargs: Any) -> ObservationsV2Response: + self.calls.append(kwargs) + if self.fail_on_call is not None and len(self.calls) == self.fail_on_call: + raise ConnectionError("fetch failed") + + matching = [o for o in self.observations if self._matches(o, kwargs["filter"])] + start = int(kwargs["cursor"]) if kwargs["cursor"] else 0 + page = matching[start : start + kwargs["limit"]] + has_more = start + kwargs["limit"] < len(matching) + meta = {"cursor": str(start + kwargs["limit"])} if has_more else {} + return ObservationsV2Response(data=page, meta=meta) + + @staticmethod + def _matches(observation: ObservationV2, filter_json: Optional[str]) -> bool: + for condition in json.loads(filter_json) if filter_json else []: + if condition["column"] == "isRootObservation": + if observation.is_root_observation is not condition["value"]: + return False + elif condition["column"] == "startTime": + assert condition["operator"] == "<=" + if observation.start_time > datetime.fromisoformat(condition["value"]): + return False + return True + + +def make_runner(observations: List[ObservationV2]): + api = FakeObservationsApi(observations) + client = SimpleNamespace( + api=SimpleNamespace(observations=api), + create_score=MagicMock(), + flush=MagicMock(), + ) + return BatchEvaluationRunner(client), api, client # type: ignore[arg-type] + + +def mapper(*, item: ObservationV2) -> EvaluatorInputs: + return EvaluatorInputs(input=item.input, output=item.output, metadata={}) + + +def length_evaluator(*, input, output, **kwargs): + return Evaluation(name="length", value=len(output)) + + +async def run(runner: BatchEvaluationRunner, **kwargs: Any): + kwargs.setdefault("mapper", mapper) + kwargs.setdefault("evaluators", [length_evaluator]) + return await runner.run_async(**kwargs) + + +@pytest.mark.asyncio +async def test_paginates_with_cursor_and_scores_observations(): + runner, api, client = make_runner([make_observation(i) for i in range(5)]) + + result = await run(runner, fetch_batch_size=2) + + assert [call["cursor"] for call in api.calls] == [None, "2", "4"] + assert all("page" not in call for call in api.calls) + assert all( + call["fields"] == DEFAULT_BATCH_EVALUATION_FIELDS and call["filter"] is None + for call in api.calls + ) + assert result.completed is True + assert result.has_more_items is False + assert result.resume_token is None + assert result.total_items_fetched == 5 + assert result.total_items_processed == 5 + assert result.total_scores_created == 5 + assert set(result.item_evaluations) == {f"obs-{i}" for i in range(5)} + client.create_score.assert_any_call( + observation_id="obs-3", + trace_id="trace-3", + name="length", + value=len("answer 3"), + comment=None, + metadata={}, + data_type=None, + config_id=None, + ) + client.flush.assert_called_once() + + +@pytest.mark.asyncio +async def test_mapper_receives_raw_string_io_and_requested_fields(): + runner, api, _ = make_runner([make_observation(1)]) + seen: List[ObservationV2] = [] + + def recording_mapper(*, item): + seen.append(item) + return mapper(item=item) + + await run(runner, mapper=recording_mapper, fields="core,io") + + assert api.calls[0]["fields"] == "core,io" + assert isinstance(seen[0], ObservationV2) + assert seen[0].input == '{"question": 1}' + + +def test_client_and_runner_share_default_fields(): + client_default = ( + inspect.signature(Langfuse.run_batched_evaluation).parameters["fields"].default + ) + runner_default = ( + inspect.signature(BatchEvaluationRunner.run_async).parameters["fields"].default + ) + + assert client_default == runner_default == DEFAULT_BATCH_EVALUATION_FIELDS + assert "io" in DEFAULT_BATCH_EVALUATION_FIELDS.split(",") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"filter": "not json"}, "JSON array"), + ({"filter": '{"tags": ["a"]}'}, "JSON array"), + ], +) +async def test_invalid_arguments_raise_before_fetching(kwargs, message): + runner, api, _ = make_runner([make_observation(1)]) + + with pytest.raises(ValueError, match=message): + await run(runner, **kwargs) + + assert api.calls == [] + + +@pytest.mark.asyncio +async def test_max_items_aligns_pages_and_resume_continues_without_gaps(): + # Shared start times exercise ties that a timestamp-only resume would skip. + observations = [ + make_observation(i, start_time=BASE_TIME + timedelta(seconds=i // 3)) + for i in range(10) + ] + runner, api, _ = make_runner(observations) + + first = await run(runner, max_items=5, fetch_batch_size=3) + + assert [call["limit"] for call in api.calls] == [3, 2] + assert first.completed is True + assert first.has_more_items is True + assert first.total_items_fetched == 5 + token = first.resume_token + assert token is not None + assert token.cursor == "5" + assert token.items_processed == 5 + + second = await run(runner, resume_from=token, fetch_batch_size=3) + + assert second.resume_token is None + assert second.has_more_items is False + assert set(first.item_evaluations).isdisjoint(second.item_evaluations) + assert set(first.item_evaluations) | set(second.item_evaluations) == { + f"obs-{i}" for i in range(10) + } + + +@pytest.mark.asyncio +async def test_max_items_reached_on_last_page_has_no_resume_token(): + runner, _, _ = make_runner([make_observation(i) for i in range(4)]) + + result = await run(runner, max_items=4, fetch_batch_size=2) + + assert result.has_more_items is False + assert result.resume_token is None + + +@pytest.mark.asyncio +async def test_fetch_failure_returns_resume_token_for_failed_page(): + runner, api, _ = make_runner([make_observation(i) for i in range(6)]) + api.fail_on_call = 2 + + failed = await run(runner, fetch_batch_size=2, max_retries=0) + + assert failed.completed is False + assert failed.total_items_processed == 2 + token = failed.resume_token + assert token is not None + assert token.cursor == "2" + assert token.last_processed_id == "obs-4" + + api.fail_on_call = None + resumed = await run(runner, fetch_batch_size=2, resume_from=token) + + assert resumed.completed is True + assert set(resumed.item_evaluations) == {"obs-3", "obs-2", "obs-1", "obs-0"} + assert api.calls[-1]["request_options"] == {"max_retries": 3} + + +@pytest.mark.asyncio +async def test_resume_without_cursor_falls_back_to_start_time_bound(): + tied_time = BASE_TIME + timedelta(seconds=3) + runner, api, _ = make_runner( + [make_observation(i) for i in range(5)] + + [make_observation(9, observation_id="obs-tied", start_time=tied_time)] + ) + token = BatchEvaluationResumeToken( + filter=None, + last_processed_timestamp=(BASE_TIME + timedelta(seconds=3)).isoformat(), + last_processed_id="obs-3", + items_processed=2, + ) + + result = await run(runner, resume_from=token) + + assert json.loads(api.calls[0]["filter"]) == [ + { + "type": "datetime", + "column": "startTime", + "operator": "<=", + "value": token.last_processed_timestamp, + } + ] + assert api.calls[0]["cursor"] is None + assert set(result.item_evaluations) == {"obs-tied", "obs-2", "obs-1", "obs-0"} + + +@pytest.mark.asyncio +async def test_resume_reuses_the_token_filter_and_rejects_a_different_one(): + runner, api, _ = make_runner([make_observation(i) for i in range(4)]) + token_filter = json.dumps( + [{"type": "string", "column": "name", "operator": "=", "value": "x"}] + ) + token = BatchEvaluationResumeToken( + filter=token_filter, + cursor="2", + last_processed_timestamp=BASE_TIME.isoformat(), + last_processed_id="obs-2", + items_processed=2, + ) + api._matches = lambda observation, filter_json: True # type: ignore[method-assign] + + await run(runner, resume_from=token) + assert json.loads(api.calls[0]["filter"]) == json.loads(token_filter) + + with pytest.raises(ValueError, match="different filter"): + await run(runner, resume_from=token, filter="[]") + + +@pytest.mark.asyncio +async def test_observation_scores_can_be_duplicated_onto_trace(): + runner, _, client = make_runner([make_observation(1)]) + + result = await run(runner, _add_observation_scores_to_trace=True) + + assert result.total_scores_created == 2 + targets = [ + (call.kwargs.get("observation_id"), call.kwargs["trace_id"]) + for call in client.create_score.call_args_list + ] + assert targets == [("obs-1", "trace-1"), (None, "trace-1")] + + +@pytest.mark.asyncio +async def test_item_failures_and_evaluator_failures_are_tracked(): + observations = [make_observation(1), make_observation(2, trace_id=None)] + runner, _, client = make_runner(observations) + + def failing_evaluator(**kwargs): + raise RuntimeError("boom") + + def composite(*, evaluations, **kwargs): + return Evaluation(name="composite", value=len(evaluations)) + + result = await run( + runner, + evaluators=[length_evaluator, failing_evaluator], + composite_evaluator=composite, + ) + + assert result.total_items_processed == 1 + assert result.failed_item_ids == ["obs-2"] + assert result.error_summary == {"ValueError": 1} + assert result.total_evaluations_failed == 1 + assert result.total_composite_scores_created == 1 + assert [e.name for e in result.item_evaluations["obs-1"]] == [ + "length", + "composite", + ] + stats = {s.name: s for s in result.evaluator_stats} + assert stats["failing_evaluator"].failed_runs == 1 + assert stats["length_evaluator"].successful_runs == 1 + assert client.create_score.call_count == 2 + + +@pytest.mark.asyncio +async def test_async_mapper_and_evaluator_are_awaited(): + runner, _, _ = make_runner([make_observation(1)]) + + async def async_mapper(*, item): + return mapper(item=item) + + async def async_evaluator(*, output, **kwargs): + return [Evaluation(name="a", value=1), Evaluation(name="b", value=2)] + + result = await run(runner, mapper=async_mapper, evaluators=[async_evaluator]) + + assert result.total_scores_created == 2 + + +@pytest.mark.asyncio +async def test_duplicate_rows_for_one_observation_are_evaluated_once(): + # The events table can briefly return several rows for the same observation. + stale = make_observation(1) + fresh = make_observation(1).model_copy( + update={"output": "fresh answer", "updated_at": BASE_TIME + timedelta(hours=1)} + ) + runner, _, client = make_runner([make_observation(0), stale, fresh]) + seen: List[ObservationV2] = [] + + def recording_mapper(*, item): + seen.append(item) + return mapper(item=item) + + result = await run(runner, mapper=recording_mapper, fetch_batch_size=2) + + assert sorted(item.id for item in seen) == ["obs-0", "obs-1"] + assert [item.output for item in seen if item.id == "obs-1"] == ["fresh answer"] + assert result.total_items_processed == 2 + assert client.create_score.call_count == 2 diff --git a/tests/unit/test_e2e_support.py b/tests/unit/test_e2e_support.py index 8320bd2fe..98d25127c 100644 --- a/tests/unit/test_e2e_support.py +++ b/tests/unit/test_e2e_support.py @@ -1,144 +1,260 @@ +from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from langfuse.api import ObservationV2 from langfuse.api.commons.errors.not_found_error import NotFoundError -from tests.support.api_wrapper import LangfuseAPI as SupportLangfuseAPI from tests.support.retry import retry_until_ready -from tests.support.utils import get_api, wait_for_trace +from tests.support.utils import ( + TraceSnapshot, + get_api, + get_observations, + normalize_observation, + user_metadata, + wait_for_observations, + wait_for_scores, + wait_for_trace_snapshot, +) +START = datetime(2024, 1, 1, tzinfo=timezone.utc) -def test_get_api_retries_not_found(monkeypatch): - monkeypatch.setattr("tests.support.retry.sleep", lambda _: None) - attempts = {"count": 0} +def _observation(index: int = 0, **fields) -> ObservationV2: + defaults = { + "id": f"obs-{index}", + "trace_id": "trace-123", + "start_time": START + timedelta(seconds=index), + "project_id": "project", + "type": "SPAN", + "is_root_observation": False, + } + return ObservationV2(**{**defaults, **fields}) - class FakeTraceService: - def get(self, trace_id): - attempts["count"] += 1 - if attempts["count"] < 3: - raise NotFoundError( - body={ - "error": "LangfuseNotFoundError", - "message": f"Trace {trace_id} not found within authorized project", - } - ) +def _page(data, cursor=None): + return SimpleNamespace(data=data, meta=SimpleNamespace(cursor=cursor)) - return {"id": trace_id} - class FakeClient: - trace = FakeTraceService() +def _install_client(monkeypatch, **services): + monkeypatch.setattr("tests.support.retry.sleep", lambda _: None) + client = SimpleNamespace(**services) + monkeypatch.setattr("tests.support.utils.LangfuseAPI", lambda **_: client) - monkeypatch.setattr("tests.support.utils.LangfuseAPI", lambda **_: FakeClient()) - trace = get_api().trace.get("trace-123") +def test_get_api_retries_not_found(monkeypatch): + attempts = {"count": 0} - assert trace == {"id": "trace-123"} - assert attempts["count"] == 3 + def get_many(**kwargs): + attempts["count"] += 1 + if attempts["count"] < 3: + raise NotFoundError( + body={ + "error": "LangfuseNotFoundError", + "message": "Observations not found within authorized project", + } + ) -def test_get_api_retries_filtered_lists(monkeypatch): - monkeypatch.setattr("tests.support.retry.sleep", lambda _: None) + return _page([kwargs["trace_id"]]) - attempts = {"count": 0} + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) - class FakeTraceService: - def list(self, **kwargs): - attempts["count"] += 1 + response = get_api().observations.get_many(trace_id="trace-123") - if attempts["count"] < 3: - return SimpleNamespace(data=[]) + assert response.data == ["trace-123"] + assert attempts["count"] == 3 - return SimpleNamespace(data=[kwargs["name"]]) - class FakeClient: - trace = FakeTraceService() +def test_get_api_retries_filtered_lists(monkeypatch): + attempts = {"count": 0} - monkeypatch.setattr("tests.support.utils.LangfuseAPI", lambda **_: FakeClient()) + def get_many(**kwargs): + attempts["count"] += 1 + return _page([] if attempts["count"] < 3 else [kwargs["name"]]) - response = get_api().trace.list(name="ready-trace") + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) - assert response.data == ["ready-trace"] + response = get_api().observations.get_many(name="ready-observation") + + assert response.data == ["ready-observation"] assert attempts["count"] == 3 def test_get_api_retry_can_be_disabled(monkeypatch): attempts = {"count": 0} - class FakeTraceService: - def list(self, **kwargs): - attempts["count"] += 1 - return SimpleNamespace(data=[]) - - class FakeClient: - trace = FakeTraceService() + def get_many(**kwargs): + attempts["count"] += 1 + return _page([]) - monkeypatch.setattr("tests.support.utils.LangfuseAPI", lambda **_: FakeClient()) + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) - response = get_api(retry=False).trace.list(name="missing-trace") + response = get_api(retry=False).observations.get_many(name="missing") assert response.data == [] assert attempts["count"] == 1 -def test_raw_api_wrapper_retries_not_found_payload(monkeypatch): - monkeypatch.setattr("tests.support.retry.sleep", lambda _: None) +def test_normalize_observation_parses_io_and_maps_empty_strings_to_none(): + observation = normalize_observation( + _observation( + input='{"question": "hi"}', + output="plain text", + name="", + session_id="", + user_id="user-1", + ) + ) - attempts = {"count": 0} + assert observation.input == {"question": "hi"} + assert observation.output == "plain text" + assert observation.name is None + assert observation.session_id is None + assert observation.user_id == "user-1" - class FakeResponse: - def __init__(self, status_code, payload): - self.status_code = status_code - self._payload = payload - self.headers = {} - def json(self): - return self._payload +def test_user_metadata_drops_server_added_keys(): + observation = _observation( + metadata={ + "key": "value", + "scope.name": "langfuse-sdk", + "resourceAttributes.service.name": "test", + } + ) - def fake_get(*args, **kwargs): - attempts["count"] += 1 + assert user_metadata(observation) == {"key": "value"} - if attempts["count"] < 3: - return FakeResponse( - 404, - { - "error": "LangfuseNotFoundError", - "message": "Trace trace-123 not found within authorized project", - }, - ) - return FakeResponse(200, {"id": "trace-123", "observations": []}) +def test_get_observations_follows_cursor_and_sorts_by_start_time(monkeypatch): + calls = [] + pages = { + None: _page([_observation(2), _observation(0)], cursor="next"), + "next": _page([_observation(1)]), + } - monkeypatch.setattr("tests.support.api_wrapper.httpx.get", fake_get) + def get_many(**kwargs): + calls.append(kwargs) + return pages[kwargs["cursor"]] - api = SupportLangfuseAPI(username="user", password="pass", base_url="http://test") - trace = api.get_trace("trace-123") + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) - assert trace["id"] == "trace-123" - assert attempts["count"] == 3 + observations = get_observations(trace_id="trace-123") + assert [o.id for o in observations] == ["obs-0", "obs-1", "obs-2"] + assert [call["cursor"] for call in calls] == [None, "next"] + assert all(call["trace_id"] == "trace-123" for call in calls) -def test_wait_for_trace_retries_until_predicate_matches(monkeypatch): - monkeypatch.setattr("tests.support.retry.sleep", lambda _: None) +def test_wait_for_observations_polls_until_min_count(monkeypatch): attempts = {"count": 0} - class FakeTraceService: - def get(self, trace_id): - attempts["count"] += 1 - return {"id": trace_id, "observations": [1] * attempts["count"]} + def get_many(**kwargs): + attempts["count"] += 1 + return _page([_observation(i) for i in range(attempts["count"])]) + + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) - class FakeClient: - trace = FakeTraceService() + observations = wait_for_observations("trace-123", min_count=3) + + assert len(observations) == 3 + assert attempts["count"] == 3 - monkeypatch.setattr("tests.support.utils.LangfuseAPI", lambda **_: FakeClient()) - trace = wait_for_trace( - "trace-123", is_result_ready=lambda trace: len(trace["observations"]) == 3 +def test_wait_for_trace_snapshot_waits_for_root_and_scores(monkeypatch): + attempts = {"observations": 0, "scores": 0} + + def get_many(**kwargs): + attempts["observations"] += 1 + observations = [_observation(1, name="child")] + if attempts["observations"] >= 2: + observations.append(_observation(0, name="root", is_root_observation=True)) + return _page(observations) + + def get_many_v3(**kwargs): + attempts["scores"] += 1 + return _page(["score"] if attempts["scores"] >= 3 else []) + + _install_client( + monkeypatch, + observations=SimpleNamespace(get_many=get_many), + scores_v3=SimpleNamespace(get_many_v3=get_many_v3), ) - assert trace["id"] == "trace-123" - assert len(trace["observations"]) == 3 - assert attempts["count"] == 3 + snapshot = wait_for_trace_snapshot( + "trace-123", + min_scores=1, + is_result_ready=lambda trace: trace.root.name == "root", + ) + + assert snapshot.root.name == "root" + assert snapshot.scores == ["score"] + assert attempts["scores"] == 3 + + +def test_trace_snapshot_aggregates_trace_attributes_like_the_platform(): + snapshot = TraceSnapshot( + id="trace-123", + observations=[ + _observation( + 0, + name="root", + trace_name="root", + is_root_observation=True, + input={"q": 1}, + metadata={"root_key": "root", "scope.name": "sdk"}, + tags=["b"], + ), + _observation( + 1, + name="child", + trace_name="explicit-name", + session_id="session-1", + user_id="user-1", + tags=["a"], + public=True, + ), + _observation(2, name="grandchild", session_id="session-2"), + ], + ) + + assert snapshot.name == "explicit-name" + assert snapshot.session_id == "session-2" + assert snapshot.user_id == "user-1" + assert snapshot.tags == ["a", "b"] + assert snapshot.public is True + assert snapshot.input == {"q": 1} + assert snapshot.metadata == {"root_key": "root"} + + +def test_trace_snapshot_name_falls_back_to_root_trace_name(): + snapshot = TraceSnapshot( + id="trace-123", + observations=[ + _observation(0, name="root", trace_name="root", is_root_observation=True), + _observation(1, name="child", trace_name="root"), + ], + ) + + assert snapshot.name == "root" + assert snapshot.session_id is None + assert snapshot.public is False + + +def test_wait_for_scores_follows_cursor_and_polls(monkeypatch): + attempts = {"count": 0} + + def get_many_v3(**kwargs): + attempts["count"] += 1 + if attempts["count"] < 2: + return _page([]) + if kwargs["cursor"] is None: + return _page(["score-1"], cursor="next") + return _page(["score-2"]) + + _install_client(monkeypatch, scores_v3=SimpleNamespace(get_many_v3=get_many_v3)) + + scores = wait_for_scores(min_count=2, trace_id="trace-123") + + assert scores == ["score-1", "score-2"] def test_retry_until_ready_clears_stale_error_after_success(monkeypatch): @@ -171,3 +287,37 @@ def operation(): assert trace["id"] == "trace-123" assert trace["attempt"] == 3 + + +def test_normalize_observation_keeps_scalar_text_io_as_strings(): + observation = normalize_observation( + ObservationV2( + id="obs", + trace_id="trace", + start_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + project_id="project", + type="SPAN", + input="2", + output="true", + ) + ) + + assert observation.input == "2" + assert observation.output == "true" + + +def test_get_observations_keeps_the_latest_row_per_observation_id(monkeypatch): + stale = _observation(0, name="stale", updated_at=START) + fresh = _observation(0, name="fresh", updated_at=START + timedelta(seconds=1)) + + def get_many(**kwargs): + return _page([fresh, stale, _observation(1)]) + + _install_client(monkeypatch, observations=SimpleNamespace(get_many=get_many)) + + observations = get_observations(trace_id="trace-123") + + assert [(o.id, o.name) for o in observations] == [ + ("obs-0", "fresh"), + ("obs-1", None), + ] diff --git a/tests/unit/test_mask_otel_spans.py b/tests/unit/test_mask_otel_spans.py index 5d1d52ee4..d9a23befb 100644 --- a/tests/unit/test_mask_otel_spans.py +++ b/tests/unit/test_mask_otel_spans.py @@ -293,7 +293,6 @@ def test_export_stage_media_processes_string_sequence_attributes(): @pytest.mark.parametrize( ("attribute_key", "expected_field"), [ - ("langfuse.trace.input", "input"), ("langfuse.observation.input", "input"), ("ai.prompt.messages", "input"), ("gcp.vertex.agent.tool_call_args", "input"), @@ -302,7 +301,6 @@ def test_export_stage_media_processes_string_sequence_attributes(): ("gen_ai.input.messages", "input"), ("gen_ai.prompt.0.content", "input"), ("llm.input_messages.0.message.content", "input"), - ("langfuse.trace.output", "output"), ("langfuse.observation.output", "output"), ("ai.response.toolCalls", "output"), ("gcp.vertex.agent.tool_response", "output"), diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index ccdec39fd..6c4b23d3e 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -498,8 +498,8 @@ def test_generation_name_update(self, langfuse_client, memory_exporter): def test_trace_update(self, langfuse_client, memory_exporter): """Test updating trace level attributes.""" - # Create a span and set trace attributes using propagate_attributes and set_trace_io - with langfuse_client.start_as_current_observation(name="trace-span") as span: + # Create a span and set trace attributes using propagate_attributes + with langfuse_client.start_as_current_observation(name="trace-span"): with propagate_attributes( trace_name="updated-trace-name", user_id="test-user", @@ -507,7 +507,7 @@ def test_trace_update(self, langfuse_client, memory_exporter): tags=["tag1", "tag2"], metadata={"trace-meta": "data"}, ): - span.set_trace_io(input={"trace-input": "value"}) + pass # Get the span data spans = self.get_spans_by_name(memory_exporter, "trace-span") @@ -526,12 +526,10 @@ def test_trace_update(self, langfuse_client, memory_exporter): else: tags = list(attributes[LangfuseOtelSpanAttributes.TRACE_TAGS]) - input_data = json.loads(attributes[LangfuseOtelSpanAttributes.TRACE_INPUT]) metadata = attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.trace-meta"] # Check attribute values assert sorted(tags) == sorted(["tag1", "tag2"]) - assert input_data == {"trace-input": "value"} assert metadata == "data" def test_complex_scenario(self, langfuse_client, memory_exporter): diff --git a/tests/unit/test_resource_manager.py b/tests/unit/test_resource_manager.py index f66a1e052..f48e61555 100644 --- a/tests/unit/test_resource_manager.py +++ b/tests/unit/test_resource_manager.py @@ -1,5 +1,6 @@ """Test the LangfuseResourceManager and get_client() function.""" +import logging from queue import Queue from types import SimpleNamespace from typing import Sequence @@ -410,6 +411,36 @@ def test_at_fork_reinit_new_httpx_client_uses_configured_timeout_and_headers( client.shutdown() +def test_create_score_with_invalid_input_logs_error_without_enqueueing( + monkeypatch, caplog +): + monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false") + + with LangfuseResourceManager._lock: + LangfuseResourceManager._instances.clear() + + client = Langfuse( + public_key="pk-invalid-score", + secret_key="sk-invalid-score", + span_exporter=NoOpSpanExporter(), + ) + rm = client._resources + assert rm is not None + enqueued = [] + monkeypatch.setattr(rm, "add_score_task", lambda event, **_: enqueued.append(event)) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + client.create_score(name="invalid", value=object(), trace_id="a" * 32) + + assert enqueued == [] + assert rm._score_ingestion_queue.empty() + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) == 1 + assert "Error creating score" in error_records[0].getMessage() + + client.shutdown() + + def test_stop_and_join_consumer_threads_broadcasts_media_shutdown_after_pausing_all(): events = [] From fbe80b06d383d69f0edb5b657eeb35d3af898d81 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:33:59 +0400 Subject: [PATCH 10/31] fix(tracing)!: require the gen_ai. prefix in the default span filter (#1929) * fix(tracing)!: require the gen_ai. prefix in the default span filter is_genai_span matched any attribute key starting with "gen_ai", so lookalikes such as gen_ai_custom or gen_airflow.model exported unrelated third-party spans. Match only the gen_ai.* namespace. Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/span_filter.py | 2 +- tests/unit/test_span_filter.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/langfuse/_client/span_filter.py b/langfuse/_client/span_filter.py index 071ff48ce..cf3718545 100644 --- a/langfuse/_client/span_filter.py +++ b/langfuse/_client/span_filter.py @@ -78,7 +78,7 @@ def is_genai_span(span: ReadableSpan) -> bool: return False return any( - isinstance(key, str) and key.startswith("gen_ai") + isinstance(key, str) and key.startswith("gen_ai.") for key in span.attributes.keys() ) diff --git a/tests/unit/test_span_filter.py b/tests/unit/test_span_filter.py index cf9965d14..a639a534c 100644 --- a/tests/unit/test_span_filter.py +++ b/tests/unit/test_span_filter.py @@ -50,6 +50,30 @@ def test_is_genai_span_with_genai_attributes(): ) +@pytest.mark.parametrize("predicate", [is_genai_span, is_default_export_span]) +@pytest.mark.parametrize( + ("attributes", "expected"), + [ + ({"gen_ai": "value"}, False), + ({"gen_ai_custom": "value"}, False), + ({"gen_ai-request.model": "value"}, False), + ({"gen_airflow.model": "value"}, False), + ({"custom.gen_ai.request.model": "value"}, False), + ({b"gen_ai.request.model": "value"}, False), + ({}, False), + ({"gen_ai.request.model": "model"}, True), + ({"gen_ai.usage.input_tokens": 0}, True), + ({"gen_ai.custom": False}, True), + ({"gen_ai_custom": "value", "gen_ai.request.model": "model"}, True), + ], +) +def test_genai_filters_match_attribute_namespace(predicate, attributes, expected): + """Match gen_ai.* attributes without accepting lookalike namespaces.""" + span = _make_span(scope_name="unknown.scope", attributes=attributes) + + assert predicate(span) is expected + + def test_is_genai_span_ignores_non_string_keys(): """Ignore non-string keys when checking gen_ai.* attributes.""" assert ( From 6d8c76062a5ecb70141adbc97f6eb139ee826a63 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:20:50 +0400 Subject: [PATCH 11/31] feat(otel)!: compress span exports with gzip by default (#1954) The default OTLP exporter now gzips span batches unless configured otherwise. Resolution order: the otel_compression argument, LANGFUSE_OTEL_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, OTEL_EXPORTER_OTLP_COMPRESSION, then gzip. Values are case-insensitive and an invalid value falls through to the next setting, matching the JS SDK. Set any of them to "none" to send uncompressed. Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/client.py | 2 +- langfuse/_client/environment_variables.py | 10 ++--- langfuse/_client/span_processor.py | 53 ++++++++++++++++++----- tests/unit/test_span_processor.py | 51 ++++++++++++++++++++-- 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index fe3b09426..a588b0e99 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -251,7 +251,7 @@ def mask_otel_spans( tracer_provider(Optional[TracerProvider]): OpenTelemetry TracerProvider to use for Langfuse. This can be useful to set to have disconnected tracing between Langfuse and other OpenTelemetry-span emitting libraries. Note: To track active spans, the context is still shared between TracerProviders. This may lead to broken trace trees. id_generator (Optional[IdGenerator]): OpenTelemetry ID generator to use when Langfuse creates its own TracerProvider. If omitted, the OpenTelemetry SDK default is used. If `tracer_provider` is provided, or an OpenTelemetry TracerProvider is already registered globally, configure the ID generator on that provider instead. span_exporter (Optional[SpanExporter]): Custom OpenTelemetry span exporter for the Langfuse span processor. If omitted, Langfuse creates an OTLPSpanExporter pointed at the Langfuse OTLP endpoint. If provided, Langfuse does not wire `base_url`, exporter headers, exporter auth, exporter timeout, `otel_compression`, or the `LANGFUSE_OTEL_MAX_BATCH_SIZE_BYTES` request size limit into it. Configure endpoint, headers, timeout, and compression on the exporter instance directly. If you are sending spans to Langfuse v4 or using Langfuse Cloud Fast Preview, include `x-langfuse-ingestion-version=4` on the exporter to enable real time processing of exported spans. - otel_compression (Optional[Literal["gzip", "none"]]): Compression for span batches sent by the default OTLP span exporter. Use "gzip" to reduce network bytes or "none" to send uncompressed. Can also be set via LANGFUSE_OTEL_COMPRESSION environment variable. If unset, the standard OTEL_EXPORTER_OTLP_TRACES_COMPRESSION and OTEL_EXPORTER_OTLP_COMPRESSION environment variables apply (no compression by default). "gzip" requires Langfuse server v3.30.0 or later. + otel_compression (Optional[Literal["gzip", "none"]]): Compression for span batches sent by the default OTLP span exporter: "gzip" (default) or "none" to send uncompressed. If unset, the LANGFUSE_OTEL_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_COMPRESSION and OTEL_EXPORTER_OTLP_COMPRESSION environment variables apply, in that order, then gzip. Values are case-insensitive; an invalid value falls through to the next setting. Example: ```python diff --git a/langfuse/_client/environment_variables.py b/langfuse/_client/environment_variables.py index b340c9ac4..3d2c84a92 100644 --- a/langfuse/_client/environment_variables.py +++ b/langfuse/_client/environment_variables.py @@ -77,13 +77,13 @@ """ .. envvar:: LANGFUSE_OTEL_COMPRESSION -Compression for span batches sent by the default OTLP exporter: ``gzip`` or ``none``. -The ``otel_compression`` client argument takes precedence. If unset, the standard -``OTEL_EXPORTER_OTLP_TRACES_COMPRESSION`` and ``OTEL_EXPORTER_OTLP_COMPRESSION`` -environment variables apply. ``gzip`` requires Langfuse server v3.30.0 or later. +Compression for span batches sent by the default OTLP exporter: ``gzip`` or ``none`` +(case-insensitive). The ``otel_compression`` client argument takes precedence. If this +variable is unset or invalid, ``OTEL_EXPORTER_OTLP_TRACES_COMPRESSION`` and then +``OTEL_EXPORTER_OTLP_COMPRESSION`` apply, and otherwise span exports are gzip-compressed. Custom span exporters are not affected. -**Default value:** unset +**Default value:** unset (span exports use gzip) """ LANGFUSE_DEBUG = "LANGFUSE_DEBUG" diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index ba2f5b2de..718d854e7 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -21,6 +21,10 @@ from opentelemetry.context import Context from opentelemetry.exporter.otlp.proto.http import Compression from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, +) from opentelemetry.sdk.trace import ReadableSpan, Span from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter from opentelemetry.trace import format_span_id, format_trace_id @@ -71,23 +75,18 @@ def _resolve_max_batch_size_bytes() -> Optional[int]: _COMPRESSION_BY_NAME = {"gzip": Compression.Gzip, "none": Compression.NoCompression} -def _resolve_compression(otel_compression: Optional[str]) -> Optional[Compression]: - """Return the configured compression, or None to defer to OTEL_EXPORTER_OTLP_*COMPRESSION.""" - setting = "otel_compression" - raw_value = otel_compression - if raw_value is None: - setting = LANGFUSE_OTEL_COMPRESSION - raw_value = os.environ.get(LANGFUSE_OTEL_COMPRESSION, "") - - value = raw_value.strip().lower() +def _parse_compression( + raw_value: Optional[str], *, setting: str, warn: bool +) -> Optional[Compression]: + value = (raw_value or "").strip().lower() if not value: return None compression = _COMPRESSION_BY_NAME.get(value) - if compression is None: + if compression is None and warn: langfuse_logger.warning( - "Invalid %s=%r. Expected 'gzip' or 'none'. Falling back to the " - "OTEL_EXPORTER_OTLP_*COMPRESSION environment variables.", + "Invalid %s=%r. Expected 'gzip' or 'none'. Falling back to the next " + "compression setting, then gzip.", setting, raw_value, ) @@ -95,6 +94,36 @@ def _resolve_compression(otel_compression: Optional[str]) -> Optional[Compressio return compression +def _resolve_compression(otel_compression: Optional[str]) -> Compression: + """Resolve compression for the default exporter; gzip unless configured otherwise. + + Order: the ``otel_compression`` argument, ``LANGFUSE_OTEL_COMPRESSION``, + ``OTEL_EXPORTER_OTLP_TRACES_COMPRESSION``, ``OTEL_EXPORTER_OTLP_COMPRESSION``, + then gzip. Values are case-insensitive, and an invalid value falls through to + the next setting, matching the JS SDK. + """ + candidates = ( + ("otel_compression", otel_compression, True), + (LANGFUSE_OTEL_COMPRESSION, os.environ.get(LANGFUSE_OTEL_COMPRESSION), True), + ( + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + os.environ.get(OTEL_EXPORTER_OTLP_TRACES_COMPRESSION), + False, + ), + ( + OTEL_EXPORTER_OTLP_COMPRESSION, + os.environ.get(OTEL_EXPORTER_OTLP_COMPRESSION), + False, + ), + ) + for setting, raw_value, warn in candidates: + compression = _parse_compression(raw_value, setting=setting, warn=warn) + if compression is not None: + return compression + + return Compression.Gzip + + class LangfuseSpanProcessor(BatchSpanProcessor): """OpenTelemetry span processor that exports spans to the Langfuse API. diff --git a/tests/unit/test_span_processor.py b/tests/unit/test_span_processor.py index 9591929be..d7c5366be 100644 --- a/tests/unit/test_span_processor.py +++ b/tests/unit/test_span_processor.py @@ -134,6 +134,11 @@ def _serialized_request_size(spans: List[ReadableSpan]) -> int: return len(encode_spans(spans).SerializePartialToString()) +def _uncompressed_size(request: _RecordedRequest) -> int: + body = gzip.decompress(request.body) if request.content_encoding else request.body + return len(body) + + def _default_exporter_processor(base_url: str) -> LangfuseSpanProcessor: return LangfuseSpanProcessor( public_key="pk-test", @@ -170,7 +175,9 @@ def test_default_exporter_enforces_max_batch_size_bytes_at_boundary( expected_requests = ( [] if expected_result == SpanExportResult.FAILURE else [request_size] ) - assert [len(request.body) for request in received_requests] == expected_requests + assert [_uncompressed_size(request) for request in received_requests] == ( + expected_requests + ) def test_oversized_batch_is_dropped_on_flush_without_blocking_later_batches( @@ -199,7 +206,7 @@ def test_oversized_batch_is_dropped_on_flush_without_blocking_later_batches( finally: processor.shutdown() - assert [len(request.body) for request in received_requests] == [ + assert [_uncompressed_size(request) for request in received_requests] == [ _serialized_request_size(small_spans) ] @@ -293,10 +300,46 @@ def test_client_otel_compression_sends_gzip_request(compression_env, otlp_http_s @pytest.mark.parametrize( ("otel_compression", "env", "expected_encoding"), [ - (None, {LANGFUSE_OTEL_COMPRESSION: " GZIP "}, "gzip"), + # Nothing configured: gzip by default. + (None, {}, "gzip"), + # The argument wins over every environment variable. ("gzip", {LANGFUSE_OTEL_COMPRESSION: "none"}, "gzip"), ("none", {OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "gzip"}, None), - (None, {OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "gzip"}, "gzip"), + # LANGFUSE_OTEL_COMPRESSION wins over the OTEL variables (case-insensitive). + (None, {LANGFUSE_OTEL_COMPRESSION: " GZIP "}, "gzip"), + ( + None, + {LANGFUSE_OTEL_COMPRESSION: "None", OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"}, + None, + ), + # The traces-specific OTEL variable wins over the generic one. + ( + None, + { + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "gzip", + OTEL_EXPORTER_OTLP_COMPRESSION: "none", + }, + "gzip", + ), + (None, {OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "NONE"}, None), + (None, {OTEL_EXPORTER_OTLP_COMPRESSION: "none"}, None), + # Invalid values fall through to the next setting, then gzip. + ("brotli", {LANGFUSE_OTEL_COMPRESSION: "none"}, None), + ("brotli", {}, "gzip"), + ( + None, + {LANGFUSE_OTEL_COMPRESSION: "zstd", OTEL_EXPORTER_OTLP_COMPRESSION: "none"}, + None, + ), + ( + None, + { + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "deflate", + OTEL_EXPORTER_OTLP_COMPRESSION: "none", + }, + None, + ), + (None, {OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "bogus"}, "gzip"), ], ) def test_default_exporter_compression_precedence( From fd7ae518f20650f1d124e5d7a8f0d9351ccc8893 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:24:47 +0400 Subject: [PATCH 12/31] chore(api)!: drop references to API types removed from the v4 spec (#1955) * chore(api): drop references to API types removed from the v4 spec langfuse.model no longer re-exports DatasetRun, Observation and TraceWithFullDetails, which the regenerated API client removes. The api docstrings point to the v2 observations, scores_v3 and experiments reads instead of trace.get/list and the legacy v1 resources. Two unit tests no longer mock the removed dataset_run_items resource. Co-authored-by: Hassieb Pakzad * chore: re-run CI Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/client.py | 35 ++++++++++--------------- langfuse/model.py | 3 --- tests/unit/test_experiment.py | 7 +---- tests/unit/test_propagate_attributes.py | 8 ------ 4 files changed, 15 insertions(+), 38 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index a588b0e99..5889bd35c 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -426,23 +426,17 @@ def api(self) -> LangfuseAPI: Semantics that are easy to miss: - **Ingestion is asynchronous.** `langfuse.flush()` only guarantees delivery to - the API, not read visibility: reads such as `api.trace.get(trace_id)` may - raise `langfuse.api.NotFoundError` until processing completes (typically - within 15-30 seconds; longer under load). The same applies to scores and - dataset run reads. Instead of a fixed sleep, retry with a deadline: - - - **List endpoints return lightweight views.** `api.trace.list(...)` returns - `TraceWithDetails`, where `observations` and `scores` are lists of ID strings. - Fetch the full objects with `api.trace.get(trace_id)` (`TraceWithFullDetails`), - or prefer `api.observations.get_many(trace_id=...)` for row-level observation - queries. The same list-view vs. get-detail pattern applies to other resources. - - - **Prefer the v2 data APIs — they are the defaults since SDK v4.** - `api.observations` and `api.metrics` map to the high-performance - `/api/public/v2/...` endpoints and are the recommended read path. Their v1 - equivalents remain available under `api.legacy.observations_v1` / - `api.legacy.metrics_v1` but are less performant at scale, not recommended - for new workflows, and will be deprecated. + the API, not read visibility: reads such as + `api.observations.get_many(trace_id=...)` may return no data until + processing completes (typically within 15-30 seconds; longer under load). + The same applies to scores and experiment reads. Instead of a fixed sleep, + poll with a deadline (see the ingestion-lag link below). + + - **Observations are the read model.** Read trace data with + `api.observations.get_many(trace_id=...)` (`/api/public/v2/observations`, + cursor-paginated; request field groups with `fields`). A trace's name, + user, session, tags and input/output are on its root observation. Read + scores with `api.scores_v3` and experiments with `api.experiments`. - For large-scale aggregation (usage/cost by model, user, etc.), prefer the v2 Metrics API (`api.metrics.metrics(...)`) over paginating row-level data. @@ -450,7 +444,7 @@ def api(self) -> LangfuseAPI: See also: `async_api`, https://langfuse.com/docs/api-and-data-platform/features/query-via-sdk - (ingestion lag: #ingestion-lag, list vs. get: #traces-list-vs-get), + (ingestion lag: #ingestion-lag), https://langfuse.com/docs/api-and-data-platform/features/observations-api, https://langfuse.com/docs/metrics/features/metrics-api """ @@ -2247,9 +2241,8 @@ def flush(self) -> None: `flush()` guarantees data was *delivered* to the API, not that it is *readable* yet: server-side ingestion is asynchronous, so flushed data may not be queryable for 15-30 seconds — - `api.observations.get_many(trace_id=...)` may return empty results and - `api.trace.get()` may raise `langfuse.api.NotFoundError` right after a - successful flush. See the `api` property docs for a bounded retry + `api.observations.get_many(trace_id=...)` may return empty results right + after a successful flush. See the `api` property docs for a bounded retry pattern, or https://langfuse.com/docs/api-and-data-platform/features/query-via-sdk#ingestion-lag """ diff --git a/langfuse/model.py b/langfuse/model.py index 69d721597..30c6fe0f3 100644 --- a/langfuse/model.py +++ b/langfuse/model.py @@ -9,14 +9,11 @@ CreateDatasetRequest, # noqa Dataset, # noqa DatasetItem, # noqa - DatasetRun, # noqa DatasetStatus, # noqa MapValue, # noqa - Observation, # noqa Prompt, Prompt_Chat, Prompt_Text, - TraceWithFullDetails, # noqa ) from langfuse.logger import langfuse_logger diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index 02596c242..43d0856db 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -598,15 +598,11 @@ def test_dataset_experiment_without_project_id_uses_random_id( assert all(len(i) == 16 for i in ids) assert "random experiment id" in caplog.text - def test_dataset_run_uses_stable_id_url_version_and_no_run_item_post( + def test_dataset_run_uses_stable_id_url_and_version( self, langfuse_memory_client, find_spans, monkeypatch ): create_score = MagicMock() - create_run_item = MagicMock() monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) - monkeypatch.setattr( - langfuse_memory_client.api.dataset_run_items, "create", create_run_item - ) monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") version = datetime(2026, 2, 3, 4, 5, 6, tzinfo=timezone.utc) @@ -623,7 +619,6 @@ def test_dataset_run_uses_stable_id_url_version_and_no_run_item_post( ) langfuse_memory_client.flush() - create_run_item.assert_not_called() assert result.experiment_id == "eab007015b1c6f77" assert ( result.experiment_url diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 77afcd7cf..b16a89844 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -2873,14 +2873,6 @@ def test_experiment_attributes_propagate_with_dataset( ): """Test experiment attribute propagation with Langfuse dataset.""" - def fail_create_dataset_run_item(*args, **kwargs): - raise AssertionError("run_experiment must not create dataset run items") - - monkeypatch.setattr( - langfuse_client.api.dataset_run_items, - "create", - fail_create_dataset_run_item, - ) monkeypatch.setattr( langfuse_client, "_get_project_id", lambda: "test-project-id" ) From 8b9e83b4ff4a559f0ed742f5afdf72ba8a40e032 Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Fri, 9 Oct 2026 10:02:58 +0200 Subject: [PATCH 13/31] feat(api): update API spec from langfuse/langfuse 21b9b33 (#1961) * feat(api): update API spec from langfuse/langfuse 21b9b33 * fix(api): adapt the legacy score-create alias to the CreateScoresRequest union scores.create now takes a request body that is one score or a list. The legacy score_v1.create alias keeps its keyword signature, builds a CreateScoreRequest from the passed arguments, and calls scores.create(request=...). The postprocessor upgrades an alias applied by an earlier template instead of leaving it in place. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: langfuse-bot Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/api/__init__.py | 163 +- .../api/blob_storage_integrations/client.py | 24 +- .../blob_storage_integrations/raw_client.py | 24 +- .../types/blob_storage_integration_type.py | 4 + ...create_blob_storage_integration_request.py | 12 +- langfuse/api/client.py | 45 - langfuse/api/commons/__init__.py | 63 - langfuse/api/commons/types/__init__.py | 65 - langfuse/api/commons/types/base_score.py | 4 +- langfuse/api/commons/types/base_score_v1.py | 70 - .../api/commons/types/boolean_score_v1.py | 26 - .../api/commons/types/categorical_score_v1.py | 26 - langfuse/api/commons/types/dataset.py | 6 +- langfuse/api/commons/types/dataset_item.py | 5 + langfuse/api/commons/types/dataset_run.py | 63 - .../api/commons/types/dataset_run_item.py | 40 - .../commons/types/dataset_run_with_items.py | 23 - langfuse/api/commons/types/observation.py | 142 -- langfuse/api/commons/types/observation_v2.py | 4 +- .../api/commons/types/observations_view.py | 89 - .../commons/types/observations_view_single.py | 19 - langfuse/api/commons/types/score.py | 10 +- langfuse/api/commons/types/score_v1.py | 168 -- langfuse/api/commons/types/session.py | 25 - .../api/commons/types/session_with_traces.py | 21 - langfuse/api/commons/types/text_score_v1.py | 21 - langfuse/api/commons/types/trace.py | 84 - .../api/commons/types/trace_with_details.py | 43 - .../commons/types/trace_with_full_details.py | 50 - langfuse/api/dataset_run_items/__init__.py | 43 - langfuse/api/dataset_run_items/client.py | 350 ---- langfuse/api/dataset_run_items/raw_client.py | 574 ------ .../api/dataset_run_items/types/__init__.py | 44 - .../types/create_dataset_run_item_request.py | 58 - .../types/paginated_dataset_run_items.py | 23 - langfuse/api/datasets/__init__.py | 16 +- langfuse/api/datasets/client.py | 330 ---- langfuse/api/datasets/raw_client.py | 790 +------- langfuse/api/datasets/types/__init__.py | 11 +- .../datasets/types/paginated_dataset_runs.py | 23 - langfuse/api/evaluation_commons/__init__.py | 3 + .../api/evaluation_commons/types/__init__.py | 5 + .../types/evaluation_rule_filter.py | 25 +- ...tion_rule_string_object_filter_operator.py | 42 + .../string_object_evaluation_rule_filter.py | 15 +- langfuse/api/evaluators/types/cursor_meta.py | 7 +- langfuse/api/experiments/client.py | 20 +- langfuse/api/experiments/raw_client.py | 20 +- .../types/experiments_response_meta.py | 7 +- langfuse/api/ingestion/__init__.py | 9 - langfuse/api/ingestion/client.py | 28 +- langfuse/api/ingestion/raw_client.py | 28 +- langfuse/api/ingestion/types/__init__.py | 9 - .../api/ingestion/types/create_event_event.py | 4 + .../types/create_generation_event.py | 4 + .../types/create_observation_event.py | 4 + .../api/ingestion/types/create_span_event.py | 4 + .../api/ingestion/types/ingestion_event.py | 14 - .../api/ingestion/types/ingestion_response.py | 6 + langfuse/api/ingestion/types/trace_event.py | 4 + .../types/update_generation_event.py | 4 + .../types/update_observation_event.py | 4 + .../api/ingestion/types/update_span_event.py | 4 + langfuse/api/legacy/__init__.py | 22 +- langfuse/api/legacy/client.py | 42 - langfuse/api/legacy/metrics_v1/__init__.py | 40 - langfuse/api/legacy/metrics_v1/client.py | 223 --- langfuse/api/legacy/metrics_v1/raw_client.py | 331 ---- .../api/legacy/metrics_v1/types/__init__.py | 40 - .../metrics_v1/types/metrics_response.py | 26 - .../api/legacy/observations_v1/__init__.py | 43 - langfuse/api/legacy/observations_v1/client.py | 540 ------ .../api/legacy/observations_v1/raw_client.py | 776 -------- .../legacy/observations_v1/types/__init__.py | 44 - .../types/observations_views.py | 23 - langfuse/api/legacy/score_v1/client.py | 81 +- langfuse/api/metrics/client.py | 4 + langfuse/api/metrics/raw_client.py | 4 + langfuse/api/observations/client.py | 8 +- langfuse/api/observations/raw_client.py | 8 +- .../observations/types/observations_v2meta.py | 11 +- langfuse/api/opentelemetry/client.py | 4 + langfuse/api/opentelemetry/raw_client.py | 4 + .../types/organization_api_key.py | 17 +- langfuse/api/projects/client.py | 25 +- langfuse/api/projects/raw_client.py | 25 +- .../api/projects/types/api_key_response.py | 17 +- .../api/projects/types/api_key_summary.py | 17 +- langfuse/api/prompts/client.py | 56 + langfuse/api/prompts/raw_client.py | 56 + langfuse/api/scores/__init__.py | 57 +- langfuse/api/scores/client.py | 559 +----- langfuse/api/scores/raw_client.py | 813 +------- langfuse/api/scores/types/__init__.py | 59 +- .../types/create_score_batch_error.py} | 2 +- .../types/create_score_batch_request.py | 25 + .../types/create_score_batch_response.py} | 8 +- .../types/create_score_batch_results.py | 21 + .../api/scores/types/create_scores_request.py | 8 + .../scores/types/create_scores_response.py | 11 + .../api/scores/types/get_scores_response.py | 23 - .../scores/types/get_scores_response_data.py | 258 --- .../types/get_scores_response_data_boolean.py | 15 - .../get_scores_response_data_categorical.py | 15 - .../get_scores_response_data_correction.py | 15 - .../types/get_scores_response_data_numeric.py | 15 - .../types/get_scores_response_data_text.py | 15 - .../types/get_scores_response_trace_data.py | 38 - .../api/scores_v3/types/get_scores_v3meta.py | 8 +- langfuse/api/sessions/__init__.py | 40 - langfuse/api/sessions/client.py | 299 --- langfuse/api/sessions/raw_client.py | 537 ------ langfuse/api/sessions/types/__init__.py | 40 - .../api/sessions/types/paginated_sessions.py | 23 - langfuse/api/trace/__init__.py | 10 +- langfuse/api/trace/client.py | 546 ------ langfuse/api/trace/raw_client.py | 801 -------- langfuse/api/trace/types/__init__.py | 8 +- langfuse/api/trace/types/traces.py | 23 - langfuse/api/unstable/__init__.py | 48 +- langfuse/api/unstable/client.py | 19 + langfuse/api/unstable/skills/__init__.py | 85 + langfuse/api/unstable/skills/client.py | 808 ++++++++ langfuse/api/unstable/skills/raw_client.py | 1645 +++++++++++++++++ .../api/unstable/skills/types/__init__.py | 83 + .../types/create_skill_version_request.py | 20 + .../types/delete_skill_version_response.py} | 6 +- .../api/unstable/skills/types/skill_file.py | 23 + .../skills/types/skill_file_content.py | 17 + .../types/skill_file_contents_response.py} | 10 +- .../skills/types/skill_list_pagination.py} | 9 +- .../api/unstable/skills/types/skill_meta.py | 34 + .../skills/types/skill_meta_list_response.py | 17 + .../unstable/skills/types/skill_version.py | 36 + .../types/skill_version_file_create_input.py | 10 + .../skills/types/skill_version_file_input.py | 18 + .../types/skill_version_file_reference.py | 22 + .../types/update_skill_labels_request.py} | 7 +- .../skills/types/update_skill_request.py | 17 + langfuse/api/utils/__init__.py | 5 +- langfuse/api/utils/pagination/__init__.py | 9 +- .../api/utils/pagination/types/__init__.py | 8 +- .../pagination/types/cursor_meta_response.py | 21 + scripts/patch_generated_score_compat.py | 125 +- .../unit/test_patch_generated_score_compat.py | 57 +- 145 files changed, 3870 insertions(+), 9939 deletions(-) delete mode 100644 langfuse/api/commons/types/base_score_v1.py delete mode 100644 langfuse/api/commons/types/boolean_score_v1.py delete mode 100644 langfuse/api/commons/types/categorical_score_v1.py delete mode 100644 langfuse/api/commons/types/dataset_run.py delete mode 100644 langfuse/api/commons/types/dataset_run_item.py delete mode 100644 langfuse/api/commons/types/dataset_run_with_items.py delete mode 100644 langfuse/api/commons/types/observation.py delete mode 100644 langfuse/api/commons/types/observations_view.py delete mode 100644 langfuse/api/commons/types/observations_view_single.py delete mode 100644 langfuse/api/commons/types/score_v1.py delete mode 100644 langfuse/api/commons/types/session.py delete mode 100644 langfuse/api/commons/types/session_with_traces.py delete mode 100644 langfuse/api/commons/types/text_score_v1.py delete mode 100644 langfuse/api/commons/types/trace.py delete mode 100644 langfuse/api/commons/types/trace_with_details.py delete mode 100644 langfuse/api/commons/types/trace_with_full_details.py delete mode 100644 langfuse/api/dataset_run_items/__init__.py delete mode 100644 langfuse/api/dataset_run_items/client.py delete mode 100644 langfuse/api/dataset_run_items/raw_client.py delete mode 100644 langfuse/api/dataset_run_items/types/__init__.py delete mode 100644 langfuse/api/dataset_run_items/types/create_dataset_run_item_request.py delete mode 100644 langfuse/api/dataset_run_items/types/paginated_dataset_run_items.py delete mode 100644 langfuse/api/datasets/types/paginated_dataset_runs.py create mode 100644 langfuse/api/evaluation_commons/types/evaluation_rule_string_object_filter_operator.py delete mode 100644 langfuse/api/legacy/metrics_v1/__init__.py delete mode 100644 langfuse/api/legacy/metrics_v1/client.py delete mode 100644 langfuse/api/legacy/metrics_v1/raw_client.py delete mode 100644 langfuse/api/legacy/metrics_v1/types/__init__.py delete mode 100644 langfuse/api/legacy/metrics_v1/types/metrics_response.py delete mode 100644 langfuse/api/legacy/observations_v1/__init__.py delete mode 100644 langfuse/api/legacy/observations_v1/client.py delete mode 100644 langfuse/api/legacy/observations_v1/raw_client.py delete mode 100644 langfuse/api/legacy/observations_v1/types/__init__.py delete mode 100644 langfuse/api/legacy/observations_v1/types/observations_views.py rename langfuse/api/{datasets/types/delete_dataset_run_response.py => scores/types/create_score_batch_error.py} (84%) create mode 100644 langfuse/api/scores/types/create_score_batch_request.py rename langfuse/api/{ingestion/types/sdk_log_body.py => scores/types/create_score_batch_response.py} (64%) create mode 100644 langfuse/api/scores/types/create_score_batch_results.py create mode 100644 langfuse/api/scores/types/create_scores_request.py create mode 100644 langfuse/api/scores/types/create_scores_response.py delete mode 100644 langfuse/api/scores/types/get_scores_response.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data_boolean.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data_categorical.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data_correction.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data_numeric.py delete mode 100644 langfuse/api/scores/types/get_scores_response_data_text.py delete mode 100644 langfuse/api/scores/types/get_scores_response_trace_data.py delete mode 100644 langfuse/api/sessions/__init__.py delete mode 100644 langfuse/api/sessions/client.py delete mode 100644 langfuse/api/sessions/raw_client.py delete mode 100644 langfuse/api/sessions/types/__init__.py delete mode 100644 langfuse/api/sessions/types/paginated_sessions.py delete mode 100644 langfuse/api/trace/types/traces.py create mode 100644 langfuse/api/unstable/skills/__init__.py create mode 100644 langfuse/api/unstable/skills/client.py create mode 100644 langfuse/api/unstable/skills/raw_client.py create mode 100644 langfuse/api/unstable/skills/types/__init__.py create mode 100644 langfuse/api/unstable/skills/types/create_skill_version_request.py rename langfuse/api/{trace/types/sort.py => unstable/skills/types/delete_skill_version_response.py} (62%) create mode 100644 langfuse/api/unstable/skills/types/skill_file.py create mode 100644 langfuse/api/unstable/skills/types/skill_file_content.py rename langfuse/api/{commons/types/numeric_score_v1.py => unstable/skills/types/skill_file_contents_response.py} (52%) rename langfuse/api/{legacy/observations_v1/types/observations.py => unstable/skills/types/skill_list_pagination.py} (57%) create mode 100644 langfuse/api/unstable/skills/types/skill_meta.py create mode 100644 langfuse/api/unstable/skills/types/skill_meta_list_response.py create mode 100644 langfuse/api/unstable/skills/types/skill_version.py create mode 100644 langfuse/api/unstable/skills/types/skill_version_file_create_input.py create mode 100644 langfuse/api/unstable/skills/types/skill_version_file_input.py create mode 100644 langfuse/api/unstable/skills/types/skill_version_file_reference.py rename langfuse/api/{ingestion/types/sdk_log_event.py => unstable/skills/types/update_skill_labels_request.py} (60%) create mode 100644 langfuse/api/unstable/skills/types/update_skill_request.py create mode 100644 langfuse/api/utils/pagination/types/cursor_meta_response.py diff --git a/langfuse/api/__init__.py b/langfuse/api/__init__.py index ec4d25b20..55f330350 100644 --- a/langfuse/api/__init__.py +++ b/langfuse/api/__init__.py @@ -12,7 +12,6 @@ comments, commons, dataset_items, - dataset_run_items, datasets, evaluation_commons, evaluation_errors, @@ -37,7 +36,6 @@ score_configs, scores, scores_v3, - sessions, trace, unstable, utils, @@ -81,11 +79,8 @@ from .commons import ( AccessDeniedError, BaseScore, - BaseScoreV1, BooleanScore, - BooleanScoreV1, CategoricalScore, - CategoricalScoreV1, Comment, CommentObjectType, ConfigCategory, @@ -96,9 +91,6 @@ DatasetItemMediaReference, DatasetItemMediaReferenceField, DatasetItemMediaReferenceMedia, - DatasetRun, - DatasetRunItem, - DatasetRunWithItems, DatasetStatus, Deprecation, Error, @@ -109,12 +101,8 @@ ModelUsageUnit, NotFoundError, NumericScore, - NumericScoreV1, - Observation, ObservationLevel, ObservationV2, - ObservationsView, - ObservationsViewSingle, PricingTier, PricingTierAttributeCondition, PricingTierAttributeSource, @@ -129,23 +117,12 @@ ScoreConfigDataType, ScoreDataType, ScoreSource, - ScoreV1, - ScoreV1_Boolean, - ScoreV1_Categorical, - ScoreV1_Numeric, - ScoreV1_Text, Score_Boolean, Score_Categorical, Score_Correction, Score_Numeric, Score_Text, - Session, - SessionWithTraces, TextScore, - TextScoreV1, - Trace, - TraceWithDetails, - TraceWithFullDetails, UnauthorizedError, Usage, ) @@ -154,13 +131,7 @@ DeleteDatasetItemResponse, PaginatedDatasetItems, ) - from .dataset_run_items import CreateDatasetRunItemRequest, PaginatedDatasetRunItems - from .datasets import ( - CreateDatasetRequest, - DeleteDatasetRunResponse, - PaginatedDatasetRuns, - PaginatedDatasets, - ) + from .datasets import CreateDatasetRequest, PaginatedDatasets from .evaluation_commons import ( ArrayOptionsEvaluationRuleFilter, BooleanEvaluationRuleFilter, @@ -187,6 +158,7 @@ EvaluationRuleReadFilterBase, EvaluationRuleReadFilterWithKey, EvaluationRuleStringFilterOperator, + EvaluationRuleStringObjectFilterOperator, EvaluatorOutputDefinition, EvaluatorOutputDefinitionBase, EvaluatorOutputDefinition_Boolean, @@ -304,7 +276,6 @@ IngestionEvent_ObservationCreate, IngestionEvent_ObservationUpdate, IngestionEvent_ScoreCreate, - IngestionEvent_SdkLog, IngestionEvent_SpanCreate, IngestionEvent_SpanUpdate, IngestionEvent_TraceCreate, @@ -319,8 +290,6 @@ OptionalObservationBody, ScoreBody, ScoreEvent, - SdkLogBody, - SdkLogEvent, TraceBody, TraceEvent, UpdateEventBody, @@ -426,22 +395,15 @@ UpdateScoreConfigRequest, ) from .scores import ( + CreateScoreBatchError, + CreateScoreBatchRequest, + CreateScoreBatchResponse, + CreateScoreBatchResults, CreateScoreRequest, CreateScoreResponse, CreateScoreSource, - GetScoresResponse, - GetScoresResponseData, - GetScoresResponseDataBoolean, - GetScoresResponseDataCategorical, - GetScoresResponseDataCorrection, - GetScoresResponseDataNumeric, - GetScoresResponseDataText, - GetScoresResponseData_Boolean, - GetScoresResponseData_Categorical, - GetScoresResponseData_Correction, - GetScoresResponseData_Numeric, - GetScoresResponseData_Text, - GetScoresResponseTraceData, + CreateScoresRequest, + CreateScoresResponse, ) from .scores_v3 import ( BaseScoreV3, @@ -468,8 +430,7 @@ ScoreV3_Text, TextScoreV3, ) - from .sessions import PaginatedSessions - from .trace import DeleteTraceResponse, Sort, Traces + from .trace import DeleteTraceResponse _dynamic_imports: typing.Dict[str, str] = { "AccessDeniedError": ".commons", "AnnotationQueue": ".annotation_queues", @@ -488,7 +449,6 @@ "BaseEvent": ".ingestion", "BasePrompt": ".prompts", "BaseScore": ".commons", - "BaseScoreV1": ".commons", "BaseScoreV3": ".scores_v3", "BlobStorageExportFieldGroup": ".blob_storage_integrations", "BlobStorageExportFrequency": ".blob_storage_integrations", @@ -504,11 +464,9 @@ "BlobStorageSyncStatus": ".blob_storage_integrations", "BooleanEvaluationRuleFilter": ".evaluation_commons", "BooleanScore": ".commons", - "BooleanScoreV1": ".commons", "BooleanScoreV3": ".scores_v3", "BulkConfig": ".scim", "CategoricalScore": ".commons", - "CategoricalScoreV1": ".commons", "CategoricalScoreV3": ".scores_v3", "CategoryOptionsEvaluationRuleFilter": ".evaluation_commons", "ChatMessage": ".prompts", @@ -535,7 +493,6 @@ "CreateCommentResponse": ".comments", "CreateDatasetItemRequest": ".dataset_items", "CreateDatasetRequest": ".datasets", - "CreateDatasetRunItemRequest": ".dataset_run_items", "CreateEvaluationRuleRequest": ".evaluation_rules", "CreateEvaluatorRequest": ".evaluators", "CreateEvaluatorRequest_Code": ".evaluators", @@ -548,11 +505,17 @@ "CreateModelRequest": ".models", "CreateObservationEvent": ".ingestion", "CreatePromptRequest": ".prompts", + "CreateScoreBatchError": ".scores", + "CreateScoreBatchRequest": ".scores", + "CreateScoreBatchResponse": ".scores", + "CreateScoreBatchResults": ".scores", "CreateScoreConfigRequest": ".score_configs", "CreateScoreRequest": ".scores", "CreateScoreResponse": ".scores", "CreateScoreSource": ".scores", "CreateScoreValue": ".commons", + "CreateScoresRequest": ".scores", + "CreateScoresResponse": ".scores", "CreateSpanBody": ".ingestion", "CreateSpanEvent": ".ingestion", "CreateTextPromptRequest": ".prompts", @@ -564,15 +527,11 @@ "DatasetItemMediaReference": ".commons", "DatasetItemMediaReferenceField": ".commons", "DatasetItemMediaReferenceMedia": ".commons", - "DatasetRun": ".commons", - "DatasetRunItem": ".commons", - "DatasetRunWithItems": ".commons", "DatasetStatus": ".commons", "DateTimeEvaluationRuleFilter": ".evaluation_commons", "DeleteAnnotationQueueAssignmentResponse": ".annotation_queues", "DeleteAnnotationQueueItemResponse": ".annotation_queues", "DeleteDatasetItemResponse": ".dataset_items", - "DeleteDatasetRunResponse": ".datasets", "DeleteLlmConnectionResponse": ".llm_connections", "DeleteMembershipRequest": ".organizations", "DeleteTraceResponse": ".trace", @@ -607,6 +566,7 @@ "EvaluationRuleReadFilterBase": ".evaluation_commons", "EvaluationRuleReadFilterWithKey": ".evaluation_commons", "EvaluationRuleStringFilterOperator": ".evaluation_commons", + "EvaluationRuleStringObjectFilterOperator": ".evaluation_commons", "EvaluationRulesPage": ".evaluation_rules", "EvaluationUnauthorizedError": ".evaluation_errors", "Evaluator": ".evaluators", @@ -645,19 +605,6 @@ "GetMediaResponse": ".media", "GetMediaUploadUrlRequest": ".media", "GetMediaUploadUrlResponse": ".media", - "GetScoresResponse": ".scores", - "GetScoresResponseData": ".scores", - "GetScoresResponseDataBoolean": ".scores", - "GetScoresResponseDataCategorical": ".scores", - "GetScoresResponseDataCorrection": ".scores", - "GetScoresResponseDataNumeric": ".scores", - "GetScoresResponseDataText": ".scores", - "GetScoresResponseData_Boolean": ".scores", - "GetScoresResponseData_Categorical": ".scores", - "GetScoresResponseData_Correction": ".scores", - "GetScoresResponseData_Numeric": ".scores", - "GetScoresResponseData_Text": ".scores", - "GetScoresResponseTraceData": ".scores", "GetScoresV3Meta": ".scores_v3", "GetScoresV3Response": ".scores_v3", "HealthResponse": ".health", @@ -669,7 +616,6 @@ "IngestionEvent_ObservationCreate": ".ingestion", "IngestionEvent_ObservationUpdate": ".ingestion", "IngestionEvent_ScoreCreate": ".ingestion", - "IngestionEvent_SdkLog": ".ingestion", "IngestionEvent_SpanCreate": ".ingestion", "IngestionEvent_SpanUpdate": ".ingestion", "IngestionEvent_TraceCreate": ".ingestion", @@ -702,17 +648,13 @@ "NumberEvaluationRuleFilter": ".evaluation_commons", "NumberObjectEvaluationRuleFilter": ".evaluation_commons", "NumericScore": ".commons", - "NumericScoreV1": ".commons", "NumericScoreV3": ".scores_v3", - "Observation": ".commons", "ObservationBody": ".ingestion", "ObservationLevel": ".commons", "ObservationType": ".ingestion", "ObservationV2": ".commons", "ObservationsV2Meta": ".observations", "ObservationsV2Response": ".observations", - "ObservationsView": ".commons", - "ObservationsViewSingle": ".commons", "OpenAiCompletionUsageSchema": ".ingestion", "OpenAiResponseUsageSchema": ".ingestion", "OpenAiUsage": ".ingestion", @@ -733,12 +675,9 @@ "PaginatedAnnotationQueueItems": ".annotation_queues", "PaginatedAnnotationQueues": ".annotation_queues", "PaginatedDatasetItems": ".dataset_items", - "PaginatedDatasetRunItems": ".dataset_run_items", - "PaginatedDatasetRuns": ".datasets", "PaginatedDatasets": ".datasets", "PaginatedLlmConnections": ".llm_connections", "PaginatedModels": ".models", - "PaginatedSessions": ".sessions", "PatchMediaBody": ".media", "PlaceholderMessage": ".prompts", "PlaceholderMessageType": ".prompts", @@ -804,11 +743,6 @@ "ScoreSubjectV3_Observation": ".scores_v3", "ScoreSubjectV3_Session": ".scores_v3", "ScoreSubjectV3_Trace": ".scores_v3", - "ScoreV1": ".commons", - "ScoreV1_Boolean": ".commons", - "ScoreV1_Categorical": ".commons", - "ScoreV1_Numeric": ".commons", - "ScoreV1_Text": ".commons", "ScoreV3": ".scores_v3", "ScoreV3_Boolean": ".scores_v3", "ScoreV3_Categorical": ".scores_v3", @@ -820,13 +754,8 @@ "Score_Correction": ".commons", "Score_Numeric": ".commons", "Score_Text": ".commons", - "SdkLogBody": ".ingestion", - "SdkLogEvent": ".ingestion", "ServiceProviderConfig": ".scim", "ServiceUnavailableError": ".health", - "Session": ".commons", - "SessionWithTraces": ".commons", - "Sort": ".trace", "StringEvaluationRuleFilter": ".evaluation_commons", "StringObjectEvaluationRuleFilter": ".evaluation_commons", "StringOptionsEvaluationRuleFilter": ".evaluation_commons", @@ -834,15 +763,10 @@ "SubmitFeedbackResponse": ".feedback", "TextPrompt": ".prompts", "TextScore": ".commons", - "TextScoreV1": ".commons", "TextScoreV3": ".scores_v3", "TooManyRequestsError": ".evaluation_errors", - "Trace": ".commons", "TraceBody": ".ingestion", "TraceEvent": ".ingestion", - "TraceWithDetails": ".commons", - "TraceWithFullDetails": ".commons", - "Traces": ".trace", "UnauthorizedError": ".commons", "UpdateAnnotationQueueItemRequest": ".annotation_queues", "UpdateCodeEvaluatorRequest": ".evaluators", @@ -866,7 +790,6 @@ "comments": ".comments", "commons": ".commons", "dataset_items": ".dataset_items", - "dataset_run_items": ".dataset_run_items", "datasets": ".datasets", "evaluation_commons": ".evaluation_commons", "evaluation_errors": ".evaluation_errors", @@ -891,7 +814,6 @@ "score_configs": ".score_configs", "scores": ".scores", "scores_v3": ".scores_v3", - "sessions": ".sessions", "trace": ".trace", "unstable": ".unstable", "utils": ".utils", @@ -943,7 +865,6 @@ def __dir__(): "BaseEvent", "BasePrompt", "BaseScore", - "BaseScoreV1", "BaseScoreV3", "BlobStorageExportFieldGroup", "BlobStorageExportFrequency", @@ -959,11 +880,9 @@ def __dir__(): "BlobStorageSyncStatus", "BooleanEvaluationRuleFilter", "BooleanScore", - "BooleanScoreV1", "BooleanScoreV3", "BulkConfig", "CategoricalScore", - "CategoricalScoreV1", "CategoricalScoreV3", "CategoryOptionsEvaluationRuleFilter", "ChatMessage", @@ -990,7 +909,6 @@ def __dir__(): "CreateCommentResponse", "CreateDatasetItemRequest", "CreateDatasetRequest", - "CreateDatasetRunItemRequest", "CreateEvaluationRuleRequest", "CreateEvaluatorRequest", "CreateEvaluatorRequest_Code", @@ -1003,11 +921,17 @@ def __dir__(): "CreateModelRequest", "CreateObservationEvent", "CreatePromptRequest", + "CreateScoreBatchError", + "CreateScoreBatchRequest", + "CreateScoreBatchResponse", + "CreateScoreBatchResults", "CreateScoreConfigRequest", "CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource", "CreateScoreValue", + "CreateScoresRequest", + "CreateScoresResponse", "CreateSpanBody", "CreateSpanEvent", "CreateTextPromptRequest", @@ -1019,15 +943,11 @@ def __dir__(): "DatasetItemMediaReference", "DatasetItemMediaReferenceField", "DatasetItemMediaReferenceMedia", - "DatasetRun", - "DatasetRunItem", - "DatasetRunWithItems", "DatasetStatus", "DateTimeEvaluationRuleFilter", "DeleteAnnotationQueueAssignmentResponse", "DeleteAnnotationQueueItemResponse", "DeleteDatasetItemResponse", - "DeleteDatasetRunResponse", "DeleteLlmConnectionResponse", "DeleteMembershipRequest", "DeleteTraceResponse", @@ -1062,6 +982,7 @@ def __dir__(): "EvaluationRuleReadFilterBase", "EvaluationRuleReadFilterWithKey", "EvaluationRuleStringFilterOperator", + "EvaluationRuleStringObjectFilterOperator", "EvaluationRulesPage", "EvaluationUnauthorizedError", "Evaluator", @@ -1100,19 +1021,6 @@ def __dir__(): "GetMediaResponse", "GetMediaUploadUrlRequest", "GetMediaUploadUrlResponse", - "GetScoresResponse", - "GetScoresResponseData", - "GetScoresResponseDataBoolean", - "GetScoresResponseDataCategorical", - "GetScoresResponseDataCorrection", - "GetScoresResponseDataNumeric", - "GetScoresResponseDataText", - "GetScoresResponseData_Boolean", - "GetScoresResponseData_Categorical", - "GetScoresResponseData_Correction", - "GetScoresResponseData_Numeric", - "GetScoresResponseData_Text", - "GetScoresResponseTraceData", "GetScoresV3Meta", "GetScoresV3Response", "HealthResponse", @@ -1124,7 +1032,6 @@ def __dir__(): "IngestionEvent_ObservationCreate", "IngestionEvent_ObservationUpdate", "IngestionEvent_ScoreCreate", - "IngestionEvent_SdkLog", "IngestionEvent_SpanCreate", "IngestionEvent_SpanUpdate", "IngestionEvent_TraceCreate", @@ -1157,17 +1064,13 @@ def __dir__(): "NumberEvaluationRuleFilter", "NumberObjectEvaluationRuleFilter", "NumericScore", - "NumericScoreV1", "NumericScoreV3", - "Observation", "ObservationBody", "ObservationLevel", "ObservationType", "ObservationV2", "ObservationsV2Meta", "ObservationsV2Response", - "ObservationsView", - "ObservationsViewSingle", "OpenAiCompletionUsageSchema", "OpenAiResponseUsageSchema", "OpenAiUsage", @@ -1188,12 +1091,9 @@ def __dir__(): "PaginatedAnnotationQueueItems", "PaginatedAnnotationQueues", "PaginatedDatasetItems", - "PaginatedDatasetRunItems", - "PaginatedDatasetRuns", "PaginatedDatasets", "PaginatedLlmConnections", "PaginatedModels", - "PaginatedSessions", "PatchMediaBody", "PlaceholderMessage", "PlaceholderMessageType", @@ -1259,11 +1159,6 @@ def __dir__(): "ScoreSubjectV3_Observation", "ScoreSubjectV3_Session", "ScoreSubjectV3_Trace", - "ScoreV1", - "ScoreV1_Boolean", - "ScoreV1_Categorical", - "ScoreV1_Numeric", - "ScoreV1_Text", "ScoreV3", "ScoreV3_Boolean", "ScoreV3_Categorical", @@ -1275,13 +1170,8 @@ def __dir__(): "Score_Correction", "Score_Numeric", "Score_Text", - "SdkLogBody", - "SdkLogEvent", "ServiceProviderConfig", "ServiceUnavailableError", - "Session", - "SessionWithTraces", - "Sort", "StringEvaluationRuleFilter", "StringObjectEvaluationRuleFilter", "StringOptionsEvaluationRuleFilter", @@ -1289,15 +1179,10 @@ def __dir__(): "SubmitFeedbackResponse", "TextPrompt", "TextScore", - "TextScoreV1", "TextScoreV3", "TooManyRequestsError", - "Trace", "TraceBody", "TraceEvent", - "TraceWithDetails", - "TraceWithFullDetails", - "Traces", "UnauthorizedError", "UpdateAnnotationQueueItemRequest", "UpdateCodeEvaluatorRequest", @@ -1321,7 +1206,6 @@ def __dir__(): "comments", "commons", "dataset_items", - "dataset_run_items", "datasets", "evaluation_commons", "evaluation_errors", @@ -1346,7 +1230,6 @@ def __dir__(): "score_configs", "scores", "scores_v3", - "sessions", "trace", "unstable", "utils", diff --git a/langfuse/api/blob_storage_integrations/client.py b/langfuse/api/blob_storage_integrations/client.py index 54c827908..0e609769d 100644 --- a/langfuse/api/blob_storage_integrations/client.py +++ b/langfuse/api/blob_storage_integrations/client.py @@ -114,10 +114,10 @@ def upsert_blob_storage_integration( type : BlobStorageIntegrationType bucket_name : str - Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). + Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). For GOOGLE_CLOUD_STORAGE with default credentials, the bucket must be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. region : str - Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. + Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. Not used by GOOGLE_CLOUD_STORAGE; pass auto. export_frequency : BlobStorageExportFrequency @@ -132,13 +132,15 @@ def upsert_blob_storage_integration( export_mode : BlobStorageExportMode endpoint : typing.Optional[str] - Custom endpoint URL (required for S3_COMPATIBLE type) + Custom endpoint URL (required for S3_COMPATIBLE type). Ignored for GOOGLE_CLOUD_STORAGE. access_key_id : typing.Optional[str] - Access key ID for authentication + Access key ID for authentication. Not used for GOOGLE_CLOUD_STORAGE. secret_access_key : typing.Optional[str] - Secret access key for authentication (will be encrypted when stored) + Secret access key for authentication (will be encrypted when stored). When omitted on update, the stored secret is kept, unless the type changes between GOOGLE_CLOUD_STORAGE and another type. + + For GOOGLE_CLOUD_STORAGE, a GCP service account JSON key. On self-hosted deployments, pass `__GCS_DEFAULT_CREDENTIALS__` instead to use the deployment's Application Default Credentials; the bucket must then be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. Default credentials are not available on Langfuse Cloud. prefix : typing.Optional[str] Path prefix for exported files (must end with forward slash if provided) @@ -391,10 +393,10 @@ async def upsert_blob_storage_integration( type : BlobStorageIntegrationType bucket_name : str - Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). + Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). For GOOGLE_CLOUD_STORAGE with default credentials, the bucket must be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. region : str - Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. + Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. Not used by GOOGLE_CLOUD_STORAGE; pass auto. export_frequency : BlobStorageExportFrequency @@ -409,13 +411,15 @@ async def upsert_blob_storage_integration( export_mode : BlobStorageExportMode endpoint : typing.Optional[str] - Custom endpoint URL (required for S3_COMPATIBLE type) + Custom endpoint URL (required for S3_COMPATIBLE type). Ignored for GOOGLE_CLOUD_STORAGE. access_key_id : typing.Optional[str] - Access key ID for authentication + Access key ID for authentication. Not used for GOOGLE_CLOUD_STORAGE. secret_access_key : typing.Optional[str] - Secret access key for authentication (will be encrypted when stored) + Secret access key for authentication (will be encrypted when stored). When omitted on update, the stored secret is kept, unless the type changes between GOOGLE_CLOUD_STORAGE and another type. + + For GOOGLE_CLOUD_STORAGE, a GCP service account JSON key. On self-hosted deployments, pass `__GCS_DEFAULT_CREDENTIALS__` instead to use the deployment's Application Default Credentials; the bucket must then be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. Default credentials are not available on Langfuse Cloud. prefix : typing.Optional[str] Path prefix for exported files (must end with forward slash if provided) diff --git a/langfuse/api/blob_storage_integrations/raw_client.py b/langfuse/api/blob_storage_integrations/raw_client.py index df1bb9012..827ab1a4b 100644 --- a/langfuse/api/blob_storage_integrations/raw_client.py +++ b/langfuse/api/blob_storage_integrations/raw_client.py @@ -171,10 +171,10 @@ def upsert_blob_storage_integration( type : BlobStorageIntegrationType bucket_name : str - Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). + Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). For GOOGLE_CLOUD_STORAGE with default credentials, the bucket must be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. region : str - Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. + Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. Not used by GOOGLE_CLOUD_STORAGE; pass auto. export_frequency : BlobStorageExportFrequency @@ -189,13 +189,15 @@ def upsert_blob_storage_integration( export_mode : BlobStorageExportMode endpoint : typing.Optional[str] - Custom endpoint URL (required for S3_COMPATIBLE type) + Custom endpoint URL (required for S3_COMPATIBLE type). Ignored for GOOGLE_CLOUD_STORAGE. access_key_id : typing.Optional[str] - Access key ID for authentication + Access key ID for authentication. Not used for GOOGLE_CLOUD_STORAGE. secret_access_key : typing.Optional[str] - Secret access key for authentication (will be encrypted when stored) + Secret access key for authentication (will be encrypted when stored). When omitted on update, the stored secret is kept, unless the type changes between GOOGLE_CLOUD_STORAGE and another type. + + For GOOGLE_CLOUD_STORAGE, a GCP service account JSON key. On self-hosted deployments, pass `__GCS_DEFAULT_CREDENTIALS__` instead to use the deployment's Application Default Credentials; the bucket must then be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. Default credentials are not available on Langfuse Cloud. prefix : typing.Optional[str] Path prefix for exported files (must end with forward slash if provided) @@ -666,10 +668,10 @@ async def upsert_blob_storage_integration( type : BlobStorageIntegrationType bucket_name : str - Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). + Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). For GOOGLE_CLOUD_STORAGE with default credentials, the bucket must be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. region : str - Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. + Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. Not used by GOOGLE_CLOUD_STORAGE; pass auto. export_frequency : BlobStorageExportFrequency @@ -684,13 +686,15 @@ async def upsert_blob_storage_integration( export_mode : BlobStorageExportMode endpoint : typing.Optional[str] - Custom endpoint URL (required for S3_COMPATIBLE type) + Custom endpoint URL (required for S3_COMPATIBLE type). Ignored for GOOGLE_CLOUD_STORAGE. access_key_id : typing.Optional[str] - Access key ID for authentication + Access key ID for authentication. Not used for GOOGLE_CLOUD_STORAGE. secret_access_key : typing.Optional[str] - Secret access key for authentication (will be encrypted when stored) + Secret access key for authentication (will be encrypted when stored). When omitted on update, the stored secret is kept, unless the type changes between GOOGLE_CLOUD_STORAGE and another type. + + For GOOGLE_CLOUD_STORAGE, a GCP service account JSON key. On self-hosted deployments, pass `__GCS_DEFAULT_CREDENTIALS__` instead to use the deployment's Application Default Credentials; the bucket must then be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. Default credentials are not available on Langfuse Cloud. prefix : typing.Optional[str] Path prefix for exported files (must end with forward slash if provided) diff --git a/langfuse/api/blob_storage_integrations/types/blob_storage_integration_type.py b/langfuse/api/blob_storage_integrations/types/blob_storage_integration_type.py index 66828a62d..bd69ad0e6 100644 --- a/langfuse/api/blob_storage_integrations/types/blob_storage_integration_type.py +++ b/langfuse/api/blob_storage_integrations/types/blob_storage_integration_type.py @@ -11,12 +11,14 @@ class BlobStorageIntegrationType(enum.StrEnum): S3 = "S3" S3COMPATIBLE = "S3_COMPATIBLE" AZURE_BLOB_STORAGE = "AZURE_BLOB_STORAGE" + GOOGLE_CLOUD_STORAGE = "GOOGLE_CLOUD_STORAGE" def visit( self, s3: typing.Callable[[], T_Result], s3compatible: typing.Callable[[], T_Result], azure_blob_storage: typing.Callable[[], T_Result], + google_cloud_storage: typing.Callable[[], T_Result], ) -> T_Result: if self is BlobStorageIntegrationType.S3: return s3() @@ -24,3 +26,5 @@ def visit( return s3compatible() if self is BlobStorageIntegrationType.AZURE_BLOB_STORAGE: return azure_blob_storage() + if self is BlobStorageIntegrationType.GOOGLE_CLOUD_STORAGE: + return google_cloud_storage() diff --git a/langfuse/api/blob_storage_integrations/types/create_blob_storage_integration_request.py b/langfuse/api/blob_storage_integrations/types/create_blob_storage_integration_request.py index 2f17c0aea..600a8be84 100644 --- a/langfuse/api/blob_storage_integrations/types/create_blob_storage_integration_request.py +++ b/langfuse/api/blob_storage_integrations/types/create_blob_storage_integration_request.py @@ -28,31 +28,33 @@ class CreateBlobStorageIntegrationRequest(UniversalBaseModel): pydantic.Field() ) """ - Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). + Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens). For GOOGLE_CLOUD_STORAGE with default credentials, the bucket must be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. """ endpoint: typing.Optional[str] = pydantic.Field(default=None) """ - Custom endpoint URL (required for S3_COMPATIBLE type) + Custom endpoint URL (required for S3_COMPATIBLE type). Ignored for GOOGLE_CLOUD_STORAGE. """ region: str = pydantic.Field() """ - Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. + Storage region used by S3-compatible clients (AWS, GCS, Cloudflare R2, MinIO, Azure location IDs such as eastus, OCI). Leading and trailing whitespace is removed. The remaining value must be 1-63 letters, numbers, or hyphens, and cannot start or end with a hyphen. Examples: us-east-1, europe-west1, eastus, auto. Not used by GOOGLE_CLOUD_STORAGE; pass auto. """ access_key_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="accessKeyId") ] = pydantic.Field(default=None) """ - Access key ID for authentication + Access key ID for authentication. Not used for GOOGLE_CLOUD_STORAGE. """ secret_access_key: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="secretAccessKey") ] = pydantic.Field(default=None) """ - Secret access key for authentication (will be encrypted when stored) + Secret access key for authentication (will be encrypted when stored). When omitted on update, the stored secret is kept, unless the type changes between GOOGLE_CLOUD_STORAGE and another type. + + For GOOGLE_CLOUD_STORAGE, a GCP service account JSON key. On self-hosted deployments, pass `__GCS_DEFAULT_CREDENTIALS__` instead to use the deployment's Application Default Credentials; the bucket must then be listed in `LANGFUSE_BLOB_STORAGE_GCS_ALLOWED_BUCKETS`. Default credentials are not available on Langfuse Cloud. """ prefix: typing.Optional[str] = pydantic.Field(default=None) diff --git a/langfuse/api/client.py b/langfuse/api/client.py index 3781b4fcc..1d4de400d 100644 --- a/langfuse/api/client.py +++ b/langfuse/api/client.py @@ -18,10 +18,6 @@ ) from .comments.client import AsyncCommentsClient, CommentsClient from .dataset_items.client import AsyncDatasetItemsClient, DatasetItemsClient - from .dataset_run_items.client import ( - AsyncDatasetRunItemsClient, - DatasetRunItemsClient, - ) from .datasets.client import AsyncDatasetsClient, DatasetsClient from .evaluation_rules.client import ( AsyncEvaluationRulesClient, @@ -47,7 +43,6 @@ from .score_configs.client import AsyncScoreConfigsClient, ScoreConfigsClient from .scores.client import AsyncScoresClient, ScoresClient from .scores_v3.client import AsyncScoresV3Client, ScoresV3Client - from .sessions.client import AsyncSessionsClient, SessionsClient from .trace.client import AsyncTraceClient, TraceClient from .unstable.client import AsyncUnstableClient, UnstableClient @@ -136,7 +131,6 @@ def __init__( ] = None self._comments: typing.Optional[CommentsClient] = None self._dataset_items: typing.Optional[DatasetItemsClient] = None - self._dataset_run_items: typing.Optional[DatasetRunItemsClient] = None self._datasets: typing.Optional[DatasetsClient] = None self._evaluation_rules: typing.Optional[EvaluationRulesClient] = None self._evaluators: typing.Optional[EvaluatorsClient] = None @@ -159,7 +153,6 @@ def __init__( self._score_configs: typing.Optional[ScoreConfigsClient] = None self._scores_v3: typing.Optional[ScoresV3Client] = None self._scores: typing.Optional[ScoresClient] = None - self._sessions: typing.Optional[SessionsClient] = None self._trace: typing.Optional[TraceClient] = None self._unstable: typing.Optional[UnstableClient] = None @@ -201,16 +194,6 @@ def dataset_items(self): ) return self._dataset_items - @property - def dataset_run_items(self): - if self._dataset_run_items is None: - from .dataset_run_items.client import DatasetRunItemsClient # noqa: E402 - - self._dataset_run_items = DatasetRunItemsClient( - client_wrapper=self._client_wrapper - ) - return self._dataset_run_items - @property def datasets(self): if self._datasets is None: @@ -399,14 +382,6 @@ def scores(self): self._scores = ScoresClient(client_wrapper=self._client_wrapper) return self._scores - @property - def sessions(self): - if self._sessions is None: - from .sessions.client import SessionsClient # noqa: E402 - - self._sessions = SessionsClient(client_wrapper=self._client_wrapper) - return self._sessions - @property def trace(self): if self._trace is None: @@ -508,7 +483,6 @@ def __init__( ] = None self._comments: typing.Optional[AsyncCommentsClient] = None self._dataset_items: typing.Optional[AsyncDatasetItemsClient] = None - self._dataset_run_items: typing.Optional[AsyncDatasetRunItemsClient] = None self._datasets: typing.Optional[AsyncDatasetsClient] = None self._evaluation_rules: typing.Optional[AsyncEvaluationRulesClient] = None self._evaluators: typing.Optional[AsyncEvaluatorsClient] = None @@ -531,7 +505,6 @@ def __init__( self._score_configs: typing.Optional[AsyncScoreConfigsClient] = None self._scores_v3: typing.Optional[AsyncScoresV3Client] = None self._scores: typing.Optional[AsyncScoresClient] = None - self._sessions: typing.Optional[AsyncSessionsClient] = None self._trace: typing.Optional[AsyncTraceClient] = None self._unstable: typing.Optional[AsyncUnstableClient] = None @@ -575,16 +548,6 @@ def dataset_items(self): ) return self._dataset_items - @property - def dataset_run_items(self): - if self._dataset_run_items is None: - from .dataset_run_items.client import AsyncDatasetRunItemsClient # noqa: E402 - - self._dataset_run_items = AsyncDatasetRunItemsClient( - client_wrapper=self._client_wrapper - ) - return self._dataset_run_items - @property def datasets(self): if self._datasets is None: @@ -779,14 +742,6 @@ def scores(self): self._scores = AsyncScoresClient(client_wrapper=self._client_wrapper) return self._scores - @property - def sessions(self): - if self._sessions is None: - from .sessions.client import AsyncSessionsClient # noqa: E402 - - self._sessions = AsyncSessionsClient(client_wrapper=self._client_wrapper) - return self._sessions - @property def trace(self): if self._trace is None: diff --git a/langfuse/api/commons/__init__.py b/langfuse/api/commons/__init__.py index fffe7234d..bf6de4b6e 100644 --- a/langfuse/api/commons/__init__.py +++ b/langfuse/api/commons/__init__.py @@ -8,11 +8,8 @@ if typing.TYPE_CHECKING: from .types import ( BaseScore, - BaseScoreV1, BooleanScore, - BooleanScoreV1, CategoricalScore, - CategoricalScoreV1, Comment, CommentObjectType, ConfigCategory, @@ -23,9 +20,6 @@ DatasetItemMediaReference, DatasetItemMediaReferenceField, DatasetItemMediaReferenceMedia, - DatasetRun, - DatasetRunItem, - DatasetRunWithItems, DatasetStatus, Deprecation, MapValue, @@ -33,12 +27,8 @@ ModelPrice, ModelUsageUnit, NumericScore, - NumericScoreV1, - Observation, ObservationLevel, ObservationV2, - ObservationsView, - ObservationsViewSingle, PricingTier, PricingTierAttributeCondition, PricingTierAttributeSource, @@ -53,23 +43,12 @@ ScoreConfigDataType, ScoreDataType, ScoreSource, - ScoreV1, - ScoreV1_Boolean, - ScoreV1_Categorical, - ScoreV1_Numeric, - ScoreV1_Text, Score_Boolean, Score_Categorical, Score_Correction, Score_Numeric, Score_Text, - Session, - SessionWithTraces, TextScore, - TextScoreV1, - Trace, - TraceWithDetails, - TraceWithFullDetails, Usage, ) from .errors import ( @@ -82,11 +61,8 @@ _dynamic_imports: typing.Dict[str, str] = { "AccessDeniedError": ".errors", "BaseScore": ".types", - "BaseScoreV1": ".types", "BooleanScore": ".types", - "BooleanScoreV1": ".types", "CategoricalScore": ".types", - "CategoricalScoreV1": ".types", "Comment": ".types", "CommentObjectType": ".types", "ConfigCategory": ".types", @@ -97,9 +73,6 @@ "DatasetItemMediaReference": ".types", "DatasetItemMediaReferenceField": ".types", "DatasetItemMediaReferenceMedia": ".types", - "DatasetRun": ".types", - "DatasetRunItem": ".types", - "DatasetRunWithItems": ".types", "DatasetStatus": ".types", "Deprecation": ".types", "Error": ".errors", @@ -110,12 +83,8 @@ "ModelUsageUnit": ".types", "NotFoundError": ".errors", "NumericScore": ".types", - "NumericScoreV1": ".types", - "Observation": ".types", "ObservationLevel": ".types", "ObservationV2": ".types", - "ObservationsView": ".types", - "ObservationsViewSingle": ".types", "PricingTier": ".types", "PricingTierAttributeCondition": ".types", "PricingTierAttributeSource": ".types", @@ -130,23 +99,12 @@ "ScoreConfigDataType": ".types", "ScoreDataType": ".types", "ScoreSource": ".types", - "ScoreV1": ".types", - "ScoreV1_Boolean": ".types", - "ScoreV1_Categorical": ".types", - "ScoreV1_Numeric": ".types", - "ScoreV1_Text": ".types", "Score_Boolean": ".types", "Score_Categorical": ".types", "Score_Correction": ".types", "Score_Numeric": ".types", "Score_Text": ".types", - "Session": ".types", - "SessionWithTraces": ".types", "TextScore": ".types", - "TextScoreV1": ".types", - "Trace": ".types", - "TraceWithDetails": ".types", - "TraceWithFullDetails": ".types", "UnauthorizedError": ".errors", "Usage": ".types", } @@ -182,11 +140,8 @@ def __dir__(): __all__ = [ "AccessDeniedError", "BaseScore", - "BaseScoreV1", "BooleanScore", - "BooleanScoreV1", "CategoricalScore", - "CategoricalScoreV1", "Comment", "CommentObjectType", "ConfigCategory", @@ -197,9 +152,6 @@ def __dir__(): "DatasetItemMediaReference", "DatasetItemMediaReferenceField", "DatasetItemMediaReferenceMedia", - "DatasetRun", - "DatasetRunItem", - "DatasetRunWithItems", "DatasetStatus", "Deprecation", "Error", @@ -210,12 +162,8 @@ def __dir__(): "ModelUsageUnit", "NotFoundError", "NumericScore", - "NumericScoreV1", - "Observation", "ObservationLevel", "ObservationV2", - "ObservationsView", - "ObservationsViewSingle", "PricingTier", "PricingTierAttributeCondition", "PricingTierAttributeSource", @@ -230,23 +178,12 @@ def __dir__(): "ScoreConfigDataType", "ScoreDataType", "ScoreSource", - "ScoreV1", - "ScoreV1_Boolean", - "ScoreV1_Categorical", - "ScoreV1_Numeric", - "ScoreV1_Text", "Score_Boolean", "Score_Categorical", "Score_Correction", "Score_Numeric", "Score_Text", - "Session", - "SessionWithTraces", "TextScore", - "TextScoreV1", - "Trace", - "TraceWithDetails", - "TraceWithFullDetails", "UnauthorizedError", "Usage", ] diff --git a/langfuse/api/commons/types/__init__.py b/langfuse/api/commons/types/__init__.py index 6d3303261..412219c03 100644 --- a/langfuse/api/commons/types/__init__.py +++ b/langfuse/api/commons/types/__init__.py @@ -7,11 +7,8 @@ if typing.TYPE_CHECKING: from .base_score import BaseScore - from .base_score_v1 import BaseScoreV1 from .boolean_score import BooleanScore - from .boolean_score_v1 import BooleanScoreV1 from .categorical_score import CategoricalScore - from .categorical_score_v1 import CategoricalScoreV1 from .comment import Comment from .comment_object_type import CommentObjectType from .config_category import ConfigCategory @@ -22,9 +19,6 @@ from .dataset_item_media_reference import DatasetItemMediaReference from .dataset_item_media_reference_field import DatasetItemMediaReferenceField from .dataset_item_media_reference_media import DatasetItemMediaReferenceMedia - from .dataset_run import DatasetRun - from .dataset_run_item import DatasetRunItem - from .dataset_run_with_items import DatasetRunWithItems from .dataset_status import DatasetStatus from .deprecation import Deprecation from .map_value import MapValue @@ -32,12 +26,8 @@ from .model_price import ModelPrice from .model_usage_unit import ModelUsageUnit from .numeric_score import NumericScore - from .numeric_score_v1 import NumericScoreV1 - from .observation import Observation from .observation_level import ObservationLevel from .observation_v2 import ObservationV2 - from .observations_view import ObservationsView - from .observations_view_single import ObservationsViewSingle from .pricing_tier import PricingTier from .pricing_tier_attribute_condition import PricingTierAttributeCondition from .pricing_tier_attribute_source import PricingTierAttributeSource @@ -59,28 +49,12 @@ from .score_config_data_type import ScoreConfigDataType from .score_data_type import ScoreDataType from .score_source import ScoreSource - from .score_v1 import ( - ScoreV1, - ScoreV1_Boolean, - ScoreV1_Categorical, - ScoreV1_Numeric, - ScoreV1_Text, - ) - from .session import Session - from .session_with_traces import SessionWithTraces from .text_score import TextScore - from .text_score_v1 import TextScoreV1 - from .trace import Trace - from .trace_with_details import TraceWithDetails - from .trace_with_full_details import TraceWithFullDetails from .usage import Usage _dynamic_imports: typing.Dict[str, str] = { "BaseScore": ".base_score", - "BaseScoreV1": ".base_score_v1", "BooleanScore": ".boolean_score", - "BooleanScoreV1": ".boolean_score_v1", "CategoricalScore": ".categorical_score", - "CategoricalScoreV1": ".categorical_score_v1", "Comment": ".comment", "CommentObjectType": ".comment_object_type", "ConfigCategory": ".config_category", @@ -91,9 +65,6 @@ "DatasetItemMediaReference": ".dataset_item_media_reference", "DatasetItemMediaReferenceField": ".dataset_item_media_reference_field", "DatasetItemMediaReferenceMedia": ".dataset_item_media_reference_media", - "DatasetRun": ".dataset_run", - "DatasetRunItem": ".dataset_run_item", - "DatasetRunWithItems": ".dataset_run_with_items", "DatasetStatus": ".dataset_status", "Deprecation": ".deprecation", "MapValue": ".map_value", @@ -101,12 +72,8 @@ "ModelPrice": ".model_price", "ModelUsageUnit": ".model_usage_unit", "NumericScore": ".numeric_score", - "NumericScoreV1": ".numeric_score_v1", - "Observation": ".observation", "ObservationLevel": ".observation_level", "ObservationV2": ".observation_v2", - "ObservationsView": ".observations_view", - "ObservationsViewSingle": ".observations_view_single", "PricingTier": ".pricing_tier", "PricingTierAttributeCondition": ".pricing_tier_attribute_condition", "PricingTierAttributeSource": ".pricing_tier_attribute_source", @@ -121,23 +88,12 @@ "ScoreConfigDataType": ".score_config_data_type", "ScoreDataType": ".score_data_type", "ScoreSource": ".score_source", - "ScoreV1": ".score_v1", - "ScoreV1_Boolean": ".score_v1", - "ScoreV1_Categorical": ".score_v1", - "ScoreV1_Numeric": ".score_v1", - "ScoreV1_Text": ".score_v1", "Score_Boolean": ".score", "Score_Categorical": ".score", "Score_Correction": ".score", "Score_Numeric": ".score", "Score_Text": ".score", - "Session": ".session", - "SessionWithTraces": ".session_with_traces", "TextScore": ".text_score", - "TextScoreV1": ".text_score_v1", - "Trace": ".trace", - "TraceWithDetails": ".trace_with_details", - "TraceWithFullDetails": ".trace_with_full_details", "Usage": ".usage", } @@ -171,11 +127,8 @@ def __dir__(): __all__ = [ "BaseScore", - "BaseScoreV1", "BooleanScore", - "BooleanScoreV1", "CategoricalScore", - "CategoricalScoreV1", "Comment", "CommentObjectType", "ConfigCategory", @@ -186,9 +139,6 @@ def __dir__(): "DatasetItemMediaReference", "DatasetItemMediaReferenceField", "DatasetItemMediaReferenceMedia", - "DatasetRun", - "DatasetRunItem", - "DatasetRunWithItems", "DatasetStatus", "Deprecation", "MapValue", @@ -196,12 +146,8 @@ def __dir__(): "ModelPrice", "ModelUsageUnit", "NumericScore", - "NumericScoreV1", - "Observation", "ObservationLevel", "ObservationV2", - "ObservationsView", - "ObservationsViewSingle", "PricingTier", "PricingTierAttributeCondition", "PricingTierAttributeSource", @@ -216,22 +162,11 @@ def __dir__(): "ScoreConfigDataType", "ScoreDataType", "ScoreSource", - "ScoreV1", - "ScoreV1_Boolean", - "ScoreV1_Categorical", - "ScoreV1_Numeric", - "ScoreV1_Text", "Score_Boolean", "Score_Categorical", "Score_Correction", "Score_Numeric", "Score_Text", - "Session", - "SessionWithTraces", "TextScore", - "TextScoreV1", - "Trace", - "TraceWithDetails", - "TraceWithFullDetails", "Usage", ] diff --git a/langfuse/api/commons/types/base_score.py b/langfuse/api/commons/types/base_score.py index 44e09033c..f46240dae 100644 --- a/langfuse/api/commons/types/base_score.py +++ b/langfuse/api/commons/types/base_score.py @@ -61,7 +61,9 @@ class BaseScore(UniversalBaseModel): Comment on the score """ - metadata: typing.Any = pydantic.Field() + metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field( + default=None + ) """ Metadata associated with the score """ diff --git a/langfuse/api/commons/types/base_score_v1.py b/langfuse/api/commons/types/base_score_v1.py deleted file mode 100644 index 881b10a3b..000000000 --- a/langfuse/api/commons/types/base_score_v1.py +++ /dev/null @@ -1,70 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .score_source import ScoreSource - - -class BaseScoreV1(UniversalBaseModel): - id: str - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - name: str - source: ScoreSource - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = pydantic.Field(default=None) - """ - The observation ID associated with the score - """ - - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = pydantic.Field(default=None) - """ - The user ID of the author - """ - - comment: typing.Optional[str] = pydantic.Field(default=None) - """ - Comment on the score - """ - - metadata: typing.Any = pydantic.Field() - """ - Metadata associated with the score - """ - - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = pydantic.Field(default=None) - """ - Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range - """ - - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = pydantic.Field(default=None) - """ - The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue. - """ - - environment: str = pydantic.Field() - """ - The environment from which this score originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/boolean_score_v1.py b/langfuse/api/commons/types/boolean_score_v1.py deleted file mode 100644 index cf5425255..000000000 --- a/langfuse/api/commons/types/boolean_score_v1.py +++ /dev/null @@ -1,26 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .base_score_v1 import BaseScoreV1 - - -class BooleanScoreV1(BaseScoreV1): - value: float = pydantic.Field() - """ - The numeric value of the score. Equals 1 for "True" and 0 for "False" - """ - - string_value: typing_extensions.Annotated[ - str, FieldMetadata(alias="stringValue") - ] = pydantic.Field() - """ - The string representation of the score value. Is inferred from the numeric value and equals "True" or "False" - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/categorical_score_v1.py b/langfuse/api/commons/types/categorical_score_v1.py deleted file mode 100644 index 8f98af1a8..000000000 --- a/langfuse/api/commons/types/categorical_score_v1.py +++ /dev/null @@ -1,26 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .base_score_v1 import BaseScoreV1 - - -class CategoricalScoreV1(BaseScoreV1): - value: float = pydantic.Field() - """ - Represents the numeric category mapping of the stringValue. If no config is linked, defaults to 0. - """ - - string_value: typing_extensions.Annotated[ - str, FieldMetadata(alias="stringValue") - ] = pydantic.Field() - """ - The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/dataset.py b/langfuse/api/commons/types/dataset.py index d312b291a..b7e1d6bfe 100644 --- a/langfuse/api/commons/types/dataset.py +++ b/langfuse/api/commons/types/dataset.py @@ -23,14 +23,16 @@ class Dataset(UniversalBaseModel): """ input_schema: typing_extensions.Annotated[ - typing.Optional[typing.Any], FieldMetadata(alias="inputSchema") + typing.Optional[typing.Dict[str, typing.Any]], + FieldMetadata(alias="inputSchema"), ] = pydantic.Field(default=None) """ JSON Schema for validating dataset item inputs """ expected_output_schema: typing_extensions.Annotated[ - typing.Optional[typing.Any], FieldMetadata(alias="expectedOutputSchema") + typing.Optional[typing.Dict[str, typing.Any]], + FieldMetadata(alias="expectedOutputSchema"), ] = pydantic.Field(default=None) """ JSON Schema for validating dataset item expected outputs diff --git a/langfuse/api/commons/types/dataset_item.py b/langfuse/api/commons/types/dataset_item.py index cb1cc4cb0..2e3c367c0 100644 --- a/langfuse/api/commons/types/dataset_item.py +++ b/langfuse/api/commons/types/dataset_item.py @@ -53,6 +53,11 @@ class DatasetItem(UniversalBaseModel): updated_at: typing_extensions.Annotated[ dt.datetime, FieldMetadata(alias="updatedAt") ] + version: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + When this version of the dataset item became current. Listing dataset items with a `version` at or after this timestamp returns this item version, until the item is changed again. Self-hosted deployments that set `LANGFUSE_DATASET_SERVICE_WRITE_TO_VERSIONED_IMPLEMENTATION=false` update items in place, so there it stays at the item's first write. + """ + media_references: typing_extensions.Annotated[ typing.List[DatasetItemMediaReference], FieldMetadata(alias="mediaReferences") ] = pydantic.Field() diff --git a/langfuse/api/commons/types/dataset_run.py b/langfuse/api/commons/types/dataset_run.py deleted file mode 100644 index 0b5d8566c..000000000 --- a/langfuse/api/commons/types/dataset_run.py +++ /dev/null @@ -1,63 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class DatasetRun(UniversalBaseModel): - id: str = pydantic.Field() - """ - Unique identifier of the dataset run - """ - - name: str = pydantic.Field() - """ - Name of the dataset run - """ - - description: typing.Optional[str] = pydantic.Field(default=None) - """ - Description of the run - """ - - metadata: typing.Any = pydantic.Field() - """ - Metadata of the dataset run - """ - - dataset_id: typing_extensions.Annotated[str, FieldMetadata(alias="datasetId")] = ( - pydantic.Field() - ) - """ - Id of the associated dataset - """ - - dataset_name: typing_extensions.Annotated[ - str, FieldMetadata(alias="datasetName") - ] = pydantic.Field() - """ - Name of the associated dataset - """ - - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] = pydantic.Field() - """ - The date and time when the dataset run was created - """ - - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] = pydantic.Field() - """ - The date and time when the dataset run was last updated - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/dataset_run_item.py b/langfuse/api/commons/types/dataset_run_item.py deleted file mode 100644 index 1ee364ffa..000000000 --- a/langfuse/api/commons/types/dataset_run_item.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class DatasetRunItem(UniversalBaseModel): - id: str - dataset_run_id: typing_extensions.Annotated[ - str, FieldMetadata(alias="datasetRunId") - ] - dataset_run_name: typing_extensions.Annotated[ - str, FieldMetadata(alias="datasetRunName") - ] - dataset_item_id: typing_extensions.Annotated[ - str, FieldMetadata(alias="datasetItemId") - ] - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = pydantic.Field(default=None) - """ - The observation ID associated with this run item - """ - - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/dataset_run_with_items.py b/langfuse/api/commons/types/dataset_run_with_items.py deleted file mode 100644 index b16660b97..000000000 --- a/langfuse/api/commons/types/dataset_run_with_items.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .dataset_run import DatasetRun -from .dataset_run_item import DatasetRunItem -from .deprecation import Deprecation - - -class DatasetRunWithItems(DatasetRun): - dataset_run_items: typing_extensions.Annotated[ - typing.List[DatasetRunItem], FieldMetadata(alias="datasetRunItems") - ] - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/observation.py b/langfuse/api/commons/types/observation.py deleted file mode 100644 index 91bd50643..000000000 --- a/langfuse/api/commons/types/observation.py +++ /dev/null @@ -1,142 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .observation_level import ObservationLevel -from .usage import Usage - - -class Observation(UniversalBaseModel): - id: str = pydantic.Field() - """ - The unique identifier of the observation - """ - - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = pydantic.Field(default=None) - """ - The trace ID associated with the observation - """ - - type: str = pydantic.Field() - """ - The type of the observation - """ - - name: typing.Optional[str] = pydantic.Field(default=None) - """ - The name of the observation - """ - - start_time: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="startTime") - ] = pydantic.Field() - """ - The start time of the observation - """ - - end_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="endTime") - ] = pydantic.Field(default=None) - """ - The end time of the observation. - """ - - completion_start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="completionStartTime") - ] = pydantic.Field(default=None) - """ - The completion start time of the observation - """ - - model: typing.Optional[str] = pydantic.Field(default=None) - """ - The model used for the observation - """ - - model_parameters: typing_extensions.Annotated[ - typing.Any, FieldMetadata(alias="modelParameters") - ] = pydantic.Field() - """ - The parameters of the model used for the observation - """ - - input: typing.Any = pydantic.Field() - """ - The input data of the observation - """ - - version: typing.Optional[str] = pydantic.Field(default=None) - """ - The version of the observation - """ - - metadata: typing.Any = pydantic.Field() - """ - Additional metadata of the observation - """ - - output: typing.Any = pydantic.Field() - """ - The output data of the observation - """ - - usage: Usage = pydantic.Field() - """ - (Deprecated. Use usageDetails and costDetails instead.) The usage data of the observation - """ - - level: ObservationLevel = pydantic.Field() - """ - The level of the observation - """ - - status_message: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="statusMessage") - ] = pydantic.Field(default=None) - """ - The status message of the observation - """ - - parent_observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="parentObservationId") - ] = pydantic.Field(default=None) - """ - The parent observation ID - """ - - prompt_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="promptId") - ] = pydantic.Field(default=None) - """ - The prompt ID associated with the observation - """ - - usage_details: typing_extensions.Annotated[ - typing.Dict[str, int], FieldMetadata(alias="usageDetails") - ] = pydantic.Field() - """ - The usage details of the observation. Key is the name of the usage metric, value is the number of units consumed. The total key is the sum of all (non-total) usage metrics or the total value ingested. - """ - - cost_details: typing_extensions.Annotated[ - typing.Dict[str, float], FieldMetadata(alias="costDetails") - ] = pydantic.Field() - """ - The cost details of the observation. Key is the name of the cost metric, value is the cost in USD. The total key is the sum of all (non-total) cost metrics or the total value ingested. - """ - - environment: str = pydantic.Field() - """ - The environment from which this observation originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/observation_v2.py b/langfuse/api/commons/types/observation_v2.py index f034d8ace..9c6d9e3e6 100644 --- a/langfuse/api/commons/types/observation_v2.py +++ b/langfuse/api/commons/types/observation_v2.py @@ -152,7 +152,9 @@ class ObservationV2(UniversalBaseModel): The output data of the observation """ - metadata: typing.Optional[typing.Any] = pydantic.Field(default=None) + metadata: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field( + default=None + ) """ Additional metadata of the observation """ diff --git a/langfuse/api/commons/types/observations_view.py b/langfuse/api/commons/types/observations_view.py deleted file mode 100644 index fc97c27e7..000000000 --- a/langfuse/api/commons/types/observations_view.py +++ /dev/null @@ -1,89 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .observation import Observation - - -class ObservationsView(Observation): - prompt_name: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="promptName") - ] = pydantic.Field(default=None) - """ - The name of the prompt associated with the observation - """ - - prompt_version: typing_extensions.Annotated[ - typing.Optional[int], FieldMetadata(alias="promptVersion") - ] = pydantic.Field(default=None) - """ - The version of the prompt associated with the observation - """ - - model_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="modelId") - ] = pydantic.Field(default=None) - """ - The unique identifier of the model - """ - - input_price: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="inputPrice") - ] = pydantic.Field(default=None) - """ - The price of the input in USD - """ - - output_price: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="outputPrice") - ] = pydantic.Field(default=None) - """ - The price of the output in USD. - """ - - total_price: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="totalPrice") - ] = pydantic.Field(default=None) - """ - The total price in USD. - """ - - calculated_input_cost: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="calculatedInputCost") - ] = pydantic.Field(default=None) - """ - (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the input in USD - """ - - calculated_output_cost: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="calculatedOutputCost") - ] = pydantic.Field(default=None) - """ - (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the output in USD - """ - - calculated_total_cost: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="calculatedTotalCost") - ] = pydantic.Field(default=None) - """ - (Deprecated. Use usageDetails and costDetails instead.) The calculated total cost in USD - """ - - latency: typing.Optional[float] = pydantic.Field(default=None) - """ - The latency in seconds. - """ - - time_to_first_token: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="timeToFirstToken") - ] = pydantic.Field(default=None) - """ - The time to the first token in seconds - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/observations_view_single.py b/langfuse/api/commons/types/observations_view_single.py deleted file mode 100644 index 4c7d68ace..000000000 --- a/langfuse/api/commons/types/observations_view_single.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .deprecation import Deprecation -from .observations_view import ObservationsView - - -class ObservationsViewSingle(ObservationsView): - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/score.py b/langfuse/api/commons/types/score.py index 7b31ae39a..e0e98c9ae 100644 --- a/langfuse/api/commons/types/score.py +++ b/langfuse/api/commons/types/score.py @@ -54,7 +54,7 @@ class Score_Numeric(Base): typing.Optional[str], FieldMetadata(alias="authorUserId") ] = None comment: typing.Optional[str] = None - metadata: typing.Any + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None config_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="configId") ] = None @@ -100,7 +100,7 @@ class Score_Categorical(Base): typing.Optional[str], FieldMetadata(alias="authorUserId") ] = None comment: typing.Optional[str] = None - metadata: typing.Any + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None config_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="configId") ] = None @@ -146,7 +146,7 @@ class Score_Boolean(Base): typing.Optional[str], FieldMetadata(alias="authorUserId") ] = None comment: typing.Optional[str] = None - metadata: typing.Any + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None config_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="configId") ] = None @@ -192,7 +192,7 @@ class Score_Correction(Base): typing.Optional[str], FieldMetadata(alias="authorUserId") ] = None comment: typing.Optional[str] = None - metadata: typing.Any + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None config_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="configId") ] = None @@ -237,7 +237,7 @@ class Score_Text(Base): typing.Optional[str], FieldMetadata(alias="authorUserId") ] = None comment: typing.Optional[str] = None - metadata: typing.Any + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None config_id: typing_extensions.Annotated[ typing.Optional[str], FieldMetadata(alias="configId") ] = None diff --git a/langfuse/api/commons/types/score_v1.py b/langfuse/api/commons/types/score_v1.py deleted file mode 100644 index e17e61b89..000000000 --- a/langfuse/api/commons/types/score_v1.py +++ /dev/null @@ -1,168 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -from __future__ import annotations - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .score_source import ScoreSource - - -class ScoreV1_Numeric(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["NUMERIC"], FieldMetadata(alias="dataType") - ] = "NUMERIC" - value: float - id: str - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - name: str - source: ScoreSource - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class ScoreV1_Categorical(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["CATEGORICAL"], FieldMetadata(alias="dataType") - ] = "CATEGORICAL" - value: float - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - name: str - source: ScoreSource - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class ScoreV1_Boolean(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["BOOLEAN"], FieldMetadata(alias="dataType") - ] = "BOOLEAN" - value: float - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - name: str - source: ScoreSource - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class ScoreV1_Text(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["TEXT"], FieldMetadata(alias="dataType") - ] = "TEXT" - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[str, FieldMetadata(alias="traceId")] - name: str - source: ScoreSource - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -ScoreV1 = typing_extensions.Annotated[ - typing.Union[ScoreV1_Numeric, ScoreV1_Categorical, ScoreV1_Boolean, ScoreV1_Text], - pydantic.Field(discriminator="data_type"), -] diff --git a/langfuse/api/commons/types/session.py b/langfuse/api/commons/types/session.py deleted file mode 100644 index c254b27cd..000000000 --- a/langfuse/api/commons/types/session.py +++ /dev/null @@ -1,25 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class Session(UniversalBaseModel): - id: str - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - project_id: typing_extensions.Annotated[str, FieldMetadata(alias="projectId")] - environment: str = pydantic.Field() - """ - The environment from which this session originated. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/session_with_traces.py b/langfuse/api/commons/types/session_with_traces.py deleted file mode 100644 index e5159ade0..000000000 --- a/langfuse/api/commons/types/session_with_traces.py +++ /dev/null @@ -1,21 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .deprecation import Deprecation -from .session import Session -from .trace import Trace - - -class SessionWithTraces(Session): - traces: typing.List[Trace] - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/text_score_v1.py b/langfuse/api/commons/types/text_score_v1.py deleted file mode 100644 index 937ca281e..000000000 --- a/langfuse/api/commons/types/text_score_v1.py +++ /dev/null @@ -1,21 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .base_score_v1 import BaseScoreV1 - - -class TextScoreV1(BaseScoreV1): - string_value: typing_extensions.Annotated[ - str, FieldMetadata(alias="stringValue") - ] = pydantic.Field() - """ - The text content of the score (1-500 characters) - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/trace.py b/langfuse/api/commons/types/trace.py deleted file mode 100644 index 6eafa9fcd..000000000 --- a/langfuse/api/commons/types/trace.py +++ /dev/null @@ -1,84 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class Trace(UniversalBaseModel): - id: str = pydantic.Field() - """ - The unique identifier of a trace - """ - - timestamp: dt.datetime = pydantic.Field() - """ - The timestamp when the trace was created - """ - - name: typing.Optional[str] = pydantic.Field(default=None) - """ - The name of the trace - """ - - input: typing.Optional[typing.Any] = pydantic.Field(default=None) - """ - The input data of the trace. Can be any JSON. - """ - - output: typing.Optional[typing.Any] = pydantic.Field(default=None) - """ - The output data of the trace. Can be any JSON. - """ - - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = pydantic.Field(default=None) - """ - The session identifier associated with the trace - """ - - release: typing.Optional[str] = pydantic.Field(default=None) - """ - The release version of the application when the trace was created - """ - - version: typing.Optional[str] = pydantic.Field(default=None) - """ - The version of the trace - """ - - user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="userId") - ] = pydantic.Field(default=None) - """ - The user identifier associated with the trace - """ - - metadata: typing.Optional[typing.Any] = pydantic.Field(default=None) - """ - The metadata associated with the trace. Can be any JSON. - """ - - tags: typing.List[str] = pydantic.Field() - """ - The tags associated with the trace. - """ - - public: bool = pydantic.Field() - """ - Public traces are accessible via url without login - """ - - environment: str = pydantic.Field() - """ - The environment from which this trace originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/trace_with_details.py b/langfuse/api/commons/types/trace_with_details.py deleted file mode 100644 index 958d76901..000000000 --- a/langfuse/api/commons/types/trace_with_details.py +++ /dev/null @@ -1,43 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .trace import Trace - - -class TraceWithDetails(Trace): - html_path: typing_extensions.Annotated[str, FieldMetadata(alias="htmlPath")] = ( - pydantic.Field() - ) - """ - Path of trace in Langfuse UI - """ - - latency: typing.Optional[float] = pydantic.Field(default=None) - """ - Latency of trace in seconds - """ - - total_cost: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="totalCost") - ] = pydantic.Field(default=None) - """ - Cost of trace in USD - """ - - observations: typing.Optional[typing.List[str]] = pydantic.Field(default=None) - """ - List of observation ids - """ - - scores: typing.Optional[typing.List[str]] = pydantic.Field(default=None) - """ - List of score ids - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/commons/types/trace_with_full_details.py b/langfuse/api/commons/types/trace_with_full_details.py deleted file mode 100644 index 227730cf4..000000000 --- a/langfuse/api/commons/types/trace_with_full_details.py +++ /dev/null @@ -1,50 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .deprecation import Deprecation -from .observations_view import ObservationsView -from .score_v1 import ScoreV1 -from .trace import Trace - - -class TraceWithFullDetails(Trace): - html_path: typing_extensions.Annotated[str, FieldMetadata(alias="htmlPath")] = ( - pydantic.Field() - ) - """ - Path of trace in Langfuse UI - """ - - latency: typing.Optional[float] = pydantic.Field(default=None) - """ - Latency of trace in seconds - """ - - total_cost: typing_extensions.Annotated[ - typing.Optional[float], FieldMetadata(alias="totalCost") - ] = pydantic.Field(default=None) - """ - Cost of trace in USD - """ - - observations: typing.List[ObservationsView] = pydantic.Field() - """ - List of observations - """ - - scores: typing.List[ScoreV1] = pydantic.Field() - """ - List of scores - """ - - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/dataset_run_items/__init__.py b/langfuse/api/dataset_run_items/__init__.py deleted file mode 100644 index 9ff4e097e..000000000 --- a/langfuse/api/dataset_run_items/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import CreateDatasetRunItemRequest, PaginatedDatasetRunItems -_dynamic_imports: typing.Dict[str, str] = { - "CreateDatasetRunItemRequest": ".types", - "PaginatedDatasetRunItems": ".types", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["CreateDatasetRunItemRequest", "PaginatedDatasetRunItems"] diff --git a/langfuse/api/dataset_run_items/client.py b/langfuse/api/dataset_run_items/client.py deleted file mode 100644 index d9712be10..000000000 --- a/langfuse/api/dataset_run_items/client.py +++ /dev/null @@ -1,350 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import typing_extensions -from ..commons.types.dataset_run_item import DatasetRunItem -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.request_options import RequestOptions -from .raw_client import AsyncRawDatasetRunItemsClient, RawDatasetRunItemsClient -from .types.paginated_dataset_run_items import PaginatedDatasetRunItems - -# this is used as the default value for optional parameters -OMIT = typing.cast(typing.Any, ...) - - -class DatasetRunItemsClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawDatasetRunItemsClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawDatasetRunItemsClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawDatasetRunItemsClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, create experiment data via the SDK experiment runner or the OpenTelemetry endpoint at POST /api/public/otel/v1/traces. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def create( - self, - *, - run_name: str, - dataset_item_id: str, - run_description: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Any] = OMIT, - observation_id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - dataset_version: typing.Optional[dt.datetime] = OMIT, - created_at: typing.Optional[dt.datetime] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> DatasetRunItem: - """ - Create a dataset run item - - Parameters - ---------- - run_name : str - - dataset_item_id : str - - run_description : typing.Optional[str] - Description of the run. If run exists, description will be updated. - - metadata : typing.Optional[typing.Any] - Metadata of the dataset run, updates run if run already exists - - observation_id : typing.Optional[str] - - trace_id : typing.Optional[str] - traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId. - - dataset_version : typing.Optional[dt.datetime] - ISO 8601 timestamp (RFC 3339, Section 5.6) in UTC (e.g., "2026-01-21T14:35:42Z"). - Specifies the dataset version to use for this experiment run. - If provided, the experiment will use dataset items as they existed at or before this timestamp. - If not provided, uses the latest version of dataset items. - - created_at : typing.Optional[dt.datetime] - Optional timestamp to set the createdAt field of the dataset run item. If not provided or null, defaults to current timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DatasetRunItem - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.dataset_run_items.create( - run_name="runName", - dataset_item_id="datasetItemId", - ) - """ - _response = self._raw_client.create( - run_name=run_name, - dataset_item_id=dataset_item_id, - run_description=run_description, - metadata=metadata, - observation_id=observation_id, - trace_id=trace_id, - dataset_version=dataset_version, - created_at=created_at, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset run items are replaced by experiment items; use `GET /api/public/experiment-items?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - dataset_id: str, - run_name: str, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedDatasetRunItems: - """ - List dataset run items - - Parameters - ---------- - dataset_id : str - - run_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedDatasetRunItems - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.dataset_run_items.list( - dataset_id="datasetId", - run_name="runName", - ) - """ - _response = self._raw_client.list( - dataset_id=dataset_id, - run_name=run_name, - page=page, - limit=limit, - request_options=request_options, - ) - return _response.data - - -class AsyncDatasetRunItemsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawDatasetRunItemsClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawDatasetRunItemsClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawDatasetRunItemsClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, create experiment data via the SDK experiment runner or the OpenTelemetry endpoint at POST /api/public/otel/v1/traces. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def create( - self, - *, - run_name: str, - dataset_item_id: str, - run_description: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Any] = OMIT, - observation_id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - dataset_version: typing.Optional[dt.datetime] = OMIT, - created_at: typing.Optional[dt.datetime] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> DatasetRunItem: - """ - Create a dataset run item - - Parameters - ---------- - run_name : str - - dataset_item_id : str - - run_description : typing.Optional[str] - Description of the run. If run exists, description will be updated. - - metadata : typing.Optional[typing.Any] - Metadata of the dataset run, updates run if run already exists - - observation_id : typing.Optional[str] - - trace_id : typing.Optional[str] - traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId. - - dataset_version : typing.Optional[dt.datetime] - ISO 8601 timestamp (RFC 3339, Section 5.6) in UTC (e.g., "2026-01-21T14:35:42Z"). - Specifies the dataset version to use for this experiment run. - If provided, the experiment will use dataset items as they existed at or before this timestamp. - If not provided, uses the latest version of dataset items. - - created_at : typing.Optional[dt.datetime] - Optional timestamp to set the createdAt field of the dataset run item. If not provided or null, defaults to current timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DatasetRunItem - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.dataset_run_items.create( - run_name="runName", - dataset_item_id="datasetItemId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.create( - run_name=run_name, - dataset_item_id=dataset_item_id, - run_description=run_description, - metadata=metadata, - observation_id=observation_id, - trace_id=trace_id, - dataset_version=dataset_version, - created_at=created_at, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset run items are replaced by experiment items; use `GET /api/public/experiment-items?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - dataset_id: str, - run_name: str, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedDatasetRunItems: - """ - List dataset run items - - Parameters - ---------- - dataset_id : str - - run_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedDatasetRunItems - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.dataset_run_items.list( - dataset_id="datasetId", - run_name="runName", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.list( - dataset_id=dataset_id, - run_name=run_name, - page=page, - limit=limit, - request_options=request_options, - ) - return _response.data diff --git a/langfuse/api/dataset_run_items/raw_client.py b/langfuse/api/dataset_run_items/raw_client.py deleted file mode 100644 index 374c1ebd2..000000000 --- a/langfuse/api/dataset_run_items/raw_client.py +++ /dev/null @@ -1,574 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing -import typing_extensions -from json.decoder import JSONDecodeError - -from ..commons.errors.access_denied_error import AccessDeniedError -from ..commons.errors.error import Error -from ..commons.errors.method_not_allowed_error import MethodNotAllowedError -from ..commons.errors.not_found_error import NotFoundError -from ..commons.errors.unauthorized_error import UnauthorizedError -from ..commons.types.dataset_run_item import DatasetRunItem -from ..core.api_error import ApiError -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.pydantic_utilities import parse_obj_as -from ..core.request_options import RequestOptions -from .types.paginated_dataset_run_items import PaginatedDatasetRunItems - -# this is used as the default value for optional parameters -OMIT = typing.cast(typing.Any, ...) - - -class RawDatasetRunItemsClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, create experiment data via the SDK experiment runner or the OpenTelemetry endpoint at POST /api/public/otel/v1/traces. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def create( - self, - *, - run_name: str, - dataset_item_id: str, - run_description: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Any] = OMIT, - observation_id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - dataset_version: typing.Optional[dt.datetime] = OMIT, - created_at: typing.Optional[dt.datetime] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[DatasetRunItem]: - """ - Create a dataset run item - - Parameters - ---------- - run_name : str - - dataset_item_id : str - - run_description : typing.Optional[str] - Description of the run. If run exists, description will be updated. - - metadata : typing.Optional[typing.Any] - Metadata of the dataset run, updates run if run already exists - - observation_id : typing.Optional[str] - - trace_id : typing.Optional[str] - traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId. - - dataset_version : typing.Optional[dt.datetime] - ISO 8601 timestamp (RFC 3339, Section 5.6) in UTC (e.g., "2026-01-21T14:35:42Z"). - Specifies the dataset version to use for this experiment run. - If provided, the experiment will use dataset items as they existed at or before this timestamp. - If not provided, uses the latest version of dataset items. - - created_at : typing.Optional[dt.datetime] - Optional timestamp to set the createdAt field of the dataset run item. If not provided or null, defaults to current timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[DatasetRunItem] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/dataset-run-items", - method="POST", - json={ - "runName": run_name, - "runDescription": run_description, - "metadata": metadata, - "datasetItemId": dataset_item_id, - "observationId": observation_id, - "traceId": trace_id, - "datasetVersion": dataset_version, - "createdAt": created_at, - }, - request_options=request_options, - omit=OMIT, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - DatasetRunItem, - parse_obj_as( - type_=DatasetRunItem, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset run items are replaced by experiment items; use `GET /api/public/experiment-items?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - dataset_id: str, - run_name: str, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[PaginatedDatasetRunItems]: - """ - List dataset run items - - Parameters - ---------- - dataset_id : str - - run_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[PaginatedDatasetRunItems] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/dataset-run-items", - method="GET", - params={ - "datasetId": dataset_id, - "runName": run_name, - "page": page, - "limit": limit, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedDatasetRunItems, - parse_obj_as( - type_=PaginatedDatasetRunItems, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawDatasetRunItemsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, create experiment data via the SDK experiment runner or the OpenTelemetry endpoint at POST /api/public/otel/v1/traces. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def create( - self, - *, - run_name: str, - dataset_item_id: str, - run_description: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Any] = OMIT, - observation_id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - dataset_version: typing.Optional[dt.datetime] = OMIT, - created_at: typing.Optional[dt.datetime] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[DatasetRunItem]: - """ - Create a dataset run item - - Parameters - ---------- - run_name : str - - dataset_item_id : str - - run_description : typing.Optional[str] - Description of the run. If run exists, description will be updated. - - metadata : typing.Optional[typing.Any] - Metadata of the dataset run, updates run if run already exists - - observation_id : typing.Optional[str] - - trace_id : typing.Optional[str] - traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId. - - dataset_version : typing.Optional[dt.datetime] - ISO 8601 timestamp (RFC 3339, Section 5.6) in UTC (e.g., "2026-01-21T14:35:42Z"). - Specifies the dataset version to use for this experiment run. - If provided, the experiment will use dataset items as they existed at or before this timestamp. - If not provided, uses the latest version of dataset items. - - created_at : typing.Optional[dt.datetime] - Optional timestamp to set the createdAt field of the dataset run item. If not provided or null, defaults to current timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[DatasetRunItem] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/dataset-run-items", - method="POST", - json={ - "runName": run_name, - "runDescription": run_description, - "metadata": metadata, - "datasetItemId": dataset_item_id, - "observationId": observation_id, - "traceId": trace_id, - "datasetVersion": dataset_version, - "createdAt": created_at, - }, - request_options=request_options, - omit=OMIT, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - DatasetRunItem, - parse_obj_as( - type_=DatasetRunItem, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset run items are replaced by experiment items; use `GET /api/public/experiment-items?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - dataset_id: str, - run_name: str, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[PaginatedDatasetRunItems]: - """ - List dataset run items - - Parameters - ---------- - dataset_id : str - - run_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[PaginatedDatasetRunItems] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/dataset-run-items", - method="GET", - params={ - "datasetId": dataset_id, - "runName": run_name, - "page": page, - "limit": limit, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedDatasetRunItems, - parse_obj_as( - type_=PaginatedDatasetRunItems, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/dataset_run_items/types/__init__.py b/langfuse/api/dataset_run_items/types/__init__.py deleted file mode 100644 index b520924c0..000000000 --- a/langfuse/api/dataset_run_items/types/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .create_dataset_run_item_request import CreateDatasetRunItemRequest - from .paginated_dataset_run_items import PaginatedDatasetRunItems -_dynamic_imports: typing.Dict[str, str] = { - "CreateDatasetRunItemRequest": ".create_dataset_run_item_request", - "PaginatedDatasetRunItems": ".paginated_dataset_run_items", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["CreateDatasetRunItemRequest", "PaginatedDatasetRunItems"] diff --git a/langfuse/api/dataset_run_items/types/create_dataset_run_item_request.py b/langfuse/api/dataset_run_items/types/create_dataset_run_item_request.py deleted file mode 100644 index 20397e162..000000000 --- a/langfuse/api/dataset_run_items/types/create_dataset_run_item_request.py +++ /dev/null @@ -1,58 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class CreateDatasetRunItemRequest(UniversalBaseModel): - run_name: typing_extensions.Annotated[str, FieldMetadata(alias="runName")] - run_description: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="runDescription") - ] = pydantic.Field(default=None) - """ - Description of the run. If run exists, description will be updated. - """ - - metadata: typing.Optional[typing.Any] = pydantic.Field(default=None) - """ - Metadata of the dataset run, updates run if run already exists - """ - - dataset_item_id: typing_extensions.Annotated[ - str, FieldMetadata(alias="datasetItemId") - ] - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = pydantic.Field(default=None) - """ - traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId. - """ - - dataset_version: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="datasetVersion") - ] = pydantic.Field(default=None) - """ - ISO 8601 timestamp (RFC 3339, Section 5.6) in UTC (e.g., "2026-01-21T14:35:42Z"). - Specifies the dataset version to use for this experiment run. - If provided, the experiment will use dataset items as they existed at or before this timestamp. - If not provided, uses the latest version of dataset items. - """ - - created_at: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="createdAt") - ] = pydantic.Field(default=None) - """ - Optional timestamp to set the createdAt field of the dataset run item. If not provided or null, defaults to current timestamp. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/dataset_run_items/types/paginated_dataset_run_items.py b/langfuse/api/dataset_run_items/types/paginated_dataset_run_items.py deleted file mode 100644 index e3d3a4889..000000000 --- a/langfuse/api/dataset_run_items/types/paginated_dataset_run_items.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.dataset_run_item import DatasetRunItem -from ...commons.types.deprecation import Deprecation -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from ...utils.pagination.types.meta_response import MetaResponse - - -class PaginatedDatasetRunItems(UniversalBaseModel): - data: typing.List[DatasetRunItem] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/datasets/__init__.py b/langfuse/api/datasets/__init__.py index e9005285e..0749dcc3f 100644 --- a/langfuse/api/datasets/__init__.py +++ b/langfuse/api/datasets/__init__.py @@ -6,16 +6,9 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import ( - CreateDatasetRequest, - DeleteDatasetRunResponse, - PaginatedDatasetRuns, - PaginatedDatasets, - ) + from .types import CreateDatasetRequest, PaginatedDatasets _dynamic_imports: typing.Dict[str, str] = { "CreateDatasetRequest": ".types", - "DeleteDatasetRunResponse": ".types", - "PaginatedDatasetRuns": ".types", "PaginatedDatasets": ".types", } @@ -47,9 +40,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = [ - "CreateDatasetRequest", - "DeleteDatasetRunResponse", - "PaginatedDatasetRuns", - "PaginatedDatasets", -] +__all__ = ["CreateDatasetRequest", "PaginatedDatasets"] diff --git a/langfuse/api/datasets/client.py b/langfuse/api/datasets/client.py index 6a3b9b0c8..cfa65c752 100644 --- a/langfuse/api/datasets/client.py +++ b/langfuse/api/datasets/client.py @@ -2,14 +2,10 @@ import typing -import typing_extensions from ..commons.types.dataset import Dataset -from ..commons.types.dataset_run_with_items import DatasetRunWithItems from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from .raw_client import AsyncRawDatasetsClient, RawDatasetsClient -from .types.delete_dataset_run_response import DeleteDatasetRunResponse -from .types.paginated_dataset_runs import PaginatedDatasetRuns from .types.paginated_datasets import PaginatedDatasets # this is used as the default value for optional parameters @@ -174,157 +170,6 @@ def create( ) return _response.data - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> DatasetRunWithItems: - """ - Get a dataset run and its items - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DatasetRunWithItems - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.datasets.get_run( - dataset_name="datasetName", - run_name="runName", - ) - """ - _response = self._raw_client.get_run( - dataset_name, run_name, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def delete_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> DeleteDatasetRunResponse: - """ - Delete a dataset run and all its run items. This action is irreversible. - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DeleteDatasetRunResponse - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.datasets.delete_run( - dataset_name="datasetName", - run_name="runName", - ) - """ - _response = self._raw_client.delete_run( - dataset_name, run_name, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_runs( - self, - dataset_name: str, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedDatasetRuns: - """ - Get dataset runs - - Parameters - ---------- - dataset_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedDatasetRuns - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.datasets.get_runs( - dataset_name="datasetName", - ) - """ - _response = self._raw_client.get_runs( - dataset_name, page=page, limit=limit, request_options=request_options - ) - return _response.data - class AsyncDatasetsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -509,178 +354,3 @@ async def main() -> None: request_options=request_options, ) return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> DatasetRunWithItems: - """ - Get a dataset run and its items - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DatasetRunWithItems - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.datasets.get_run( - dataset_name="datasetName", - run_name="runName", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_run( - dataset_name, run_name, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def delete_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> DeleteDatasetRunResponse: - """ - Delete a dataset run and all its run items. This action is irreversible. - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - DeleteDatasetRunResponse - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.datasets.delete_run( - dataset_name="datasetName", - run_name="runName", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.delete_run( - dataset_name, run_name, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_runs( - self, - dataset_name: str, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedDatasetRuns: - """ - Get dataset runs - - Parameters - ---------- - dataset_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedDatasetRuns - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.datasets.get_runs( - dataset_name="datasetName", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_runs( - dataset_name, page=page, limit=limit, request_options=request_options - ) - return _response.data diff --git a/langfuse/api/datasets/raw_client.py b/langfuse/api/datasets/raw_client.py index 1b0f49b77..be48ef060 100644 --- a/langfuse/api/datasets/raw_client.py +++ b/langfuse/api/datasets/raw_client.py @@ -1,7 +1,6 @@ # This file was auto-generated by Fern from our API Definition. import typing -import typing_extensions from json.decoder import JSONDecodeError from ..commons.errors.access_denied_error import AccessDeniedError @@ -10,15 +9,12 @@ from ..commons.errors.not_found_error import NotFoundError from ..commons.errors.unauthorized_error import UnauthorizedError from ..commons.types.dataset import Dataset -from ..commons.types.dataset_run_with_items import DatasetRunWithItems from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import jsonable_encoder from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions -from .types.delete_dataset_run_response import DeleteDatasetRunResponse -from .types.paginated_dataset_runs import PaginatedDatasetRuns from .types.paginated_datasets import PaginatedDatasets # this is used as the default value for optional parameters @@ -369,618 +365,51 @@ def create( body=_response_json, ) - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[DatasetRunWithItems]: - """ - Get a dataset run and its items - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[DatasetRunWithItems] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs/{jsonable_encoder(run_name)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - DatasetRunWithItems, - parse_obj_as( - type_=DatasetRunWithItems, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def delete_run( - self, - dataset_name: str, - run_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[DeleteDatasetRunResponse]: - """ - Delete a dataset run and all its run items. This action is irreversible. - - Parameters - ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[DeleteDatasetRunResponse] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs/{jsonable_encoder(run_name)}", - method="DELETE", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - DeleteDatasetRunResponse, - parse_obj_as( - type_=DeleteDatasetRunResponse, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_runs( - self, - dataset_name: str, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[PaginatedDatasetRuns]: - """ - Get dataset runs - - Parameters - ---------- - dataset_name : str - - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[PaginatedDatasetRuns] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs", - method="GET", - params={ - "page": page, - "limit": limit, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedDatasetRuns, - parse_obj_as( - type_=PaginatedDatasetRuns, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawDatasetsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - async def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[PaginatedDatasets]: - """ - Get all datasets - - Parameters - ---------- - page : typing.Optional[int] - page number, starts at 1 - - limit : typing.Optional[int] - limit of items per page - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[PaginatedDatasets] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/v2/datasets", - method="GET", - params={ - "page": page, - "limit": limit, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedDatasets, - parse_obj_as( - type_=PaginatedDatasets, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - async def get( - self, - dataset_name: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[Dataset]: - """ - Get a dataset - - Parameters - ---------- - dataset_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[Dataset] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/v2/datasets/{jsonable_encoder(dataset_name)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - Dataset, - parse_obj_as( - type_=Dataset, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - async def create( +class AsyncRawDatasetsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( self, *, - name: str, - description: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Any] = OMIT, - input_schema: typing.Optional[typing.Any] = OMIT, - expected_output_schema: typing.Optional[typing.Any] = OMIT, + page: typing.Optional[int] = None, + limit: typing.Optional[int] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[Dataset]: + ) -> AsyncHttpResponse[PaginatedDatasets]: """ - Create a dataset + Get all datasets Parameters ---------- - name : str - - description : typing.Optional[str] - - metadata : typing.Optional[typing.Any] - - input_schema : typing.Optional[typing.Any] - JSON Schema for validating dataset item inputs. When set, all new and existing dataset items will be validated against this schema. + page : typing.Optional[int] + page number, starts at 1 - expected_output_schema : typing.Optional[typing.Any] - JSON Schema for validating dataset item expected outputs. When set, all new and existing dataset items will be validated against this schema. + limit : typing.Optional[int] + limit of items per page request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[Dataset] + AsyncHttpResponse[PaginatedDatasets] """ _response = await self._client_wrapper.httpx_client.request( "api/public/v2/datasets", - method="POST", - json={ - "name": name, - "description": description, - "metadata": metadata, - "inputSchema": input_schema, - "expectedOutputSchema": expected_output_schema, + method="GET", + params={ + "page": page, + "limit": limit, }, request_options=request_options, - omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - Dataset, + PaginatedDatasets, parse_obj_as( - type_=Dataset, # type: ignore + type_=PaginatedDatasets, # type: ignore object_=_response.json(), ), ) @@ -1053,44 +482,37 @@ async def create( body=_response_json, ) - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_run( + async def get( self, dataset_name: str, - run_name: str, *, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[DatasetRunWithItems]: + ) -> AsyncHttpResponse[Dataset]: """ - Get a dataset run and its items + Get a dataset Parameters ---------- dataset_name : str - run_name : str - request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[DatasetRunWithItems] + AsyncHttpResponse[Dataset] """ _response = await self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs/{jsonable_encoder(run_name)}", + f"api/public/v2/datasets/{jsonable_encoder(dataset_name)}", method="GET", request_options=request_options, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - DatasetRunWithItems, + Dataset, parse_obj_as( - type_=DatasetRunWithItems, # type: ignore + type_=Dataset, # type: ignore object_=_response.json(), ), ) @@ -1163,163 +585,59 @@ async def get_run( body=_response_json, ) - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def delete_run( + async def create( self, - dataset_name: str, - run_name: str, *, + name: str, + description: typing.Optional[str] = OMIT, + metadata: typing.Optional[typing.Any] = OMIT, + input_schema: typing.Optional[typing.Any] = OMIT, + expected_output_schema: typing.Optional[typing.Any] = OMIT, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[DeleteDatasetRunResponse]: + ) -> AsyncHttpResponse[Dataset]: """ - Delete a dataset run and all its run items. This action is irreversible. + Create a dataset Parameters ---------- - dataset_name : str - - run_name : str - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[DeleteDatasetRunResponse] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs/{jsonable_encoder(run_name)}", - method="DELETE", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - DeleteDatasetRunResponse, - parse_obj_as( - type_=DeleteDatasetRunResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) + name : str - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, dataset runs are replaced by experiments; use GET /api/public/experiments instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_runs( - self, - dataset_name: str, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[PaginatedDatasetRuns]: - """ - Get dataset runs + description : typing.Optional[str] - Parameters - ---------- - dataset_name : str + metadata : typing.Optional[typing.Any] - page : typing.Optional[int] - page number, starts at 1 + input_schema : typing.Optional[typing.Any] + JSON Schema for validating dataset item inputs. When set, all new and existing dataset items will be validated against this schema. - limit : typing.Optional[int] - limit of items per page + expected_output_schema : typing.Optional[typing.Any] + JSON Schema for validating dataset item expected outputs. When set, all new and existing dataset items will be validated against this schema. request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[PaginatedDatasetRuns] + AsyncHttpResponse[Dataset] """ _response = await self._client_wrapper.httpx_client.request( - f"api/public/datasets/{jsonable_encoder(dataset_name)}/runs", - method="GET", - params={ - "page": page, - "limit": limit, + "api/public/v2/datasets", + method="POST", + json={ + "name": name, + "description": description, + "metadata": metadata, + "inputSchema": input_schema, + "expectedOutputSchema": expected_output_schema, }, request_options=request_options, + omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - PaginatedDatasetRuns, + Dataset, parse_obj_as( - type_=PaginatedDatasetRuns, # type: ignore + type_=Dataset, # type: ignore object_=_response.json(), ), ) diff --git a/langfuse/api/datasets/types/__init__.py b/langfuse/api/datasets/types/__init__.py index 60c58934a..4ca73e0bc 100644 --- a/langfuse/api/datasets/types/__init__.py +++ b/langfuse/api/datasets/types/__init__.py @@ -7,13 +7,9 @@ if typing.TYPE_CHECKING: from .create_dataset_request import CreateDatasetRequest - from .delete_dataset_run_response import DeleteDatasetRunResponse - from .paginated_dataset_runs import PaginatedDatasetRuns from .paginated_datasets import PaginatedDatasets _dynamic_imports: typing.Dict[str, str] = { "CreateDatasetRequest": ".create_dataset_request", - "DeleteDatasetRunResponse": ".delete_dataset_run_response", - "PaginatedDatasetRuns": ".paginated_dataset_runs", "PaginatedDatasets": ".paginated_datasets", } @@ -45,9 +41,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = [ - "CreateDatasetRequest", - "DeleteDatasetRunResponse", - "PaginatedDatasetRuns", - "PaginatedDatasets", -] +__all__ = ["CreateDatasetRequest", "PaginatedDatasets"] diff --git a/langfuse/api/datasets/types/paginated_dataset_runs.py b/langfuse/api/datasets/types/paginated_dataset_runs.py deleted file mode 100644 index d70a9bf35..000000000 --- a/langfuse/api/datasets/types/paginated_dataset_runs.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.dataset_run import DatasetRun -from ...commons.types.deprecation import Deprecation -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from ...utils.pagination.types.meta_response import MetaResponse - - -class PaginatedDatasetRuns(UniversalBaseModel): - data: typing.List[DatasetRun] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/evaluation_commons/__init__.py b/langfuse/api/evaluation_commons/__init__.py index 00912e7ce..e16b31a7c 100644 --- a/langfuse/api/evaluation_commons/__init__.py +++ b/langfuse/api/evaluation_commons/__init__.py @@ -32,6 +32,7 @@ EvaluationRuleReadFilterBase, EvaluationRuleReadFilterWithKey, EvaluationRuleStringFilterOperator, + EvaluationRuleStringObjectFilterOperator, EvaluatorOutputDefinition, EvaluatorOutputDefinitionBase, EvaluatorOutputDefinition_Boolean, @@ -85,6 +86,7 @@ "EvaluationRuleReadFilterBase": ".types", "EvaluationRuleReadFilterWithKey": ".types", "EvaluationRuleStringFilterOperator": ".types", + "EvaluationRuleStringObjectFilterOperator": ".types", "EvaluatorOutputDefinition": ".types", "EvaluatorOutputDefinitionBase": ".types", "EvaluatorOutputDefinition_Boolean": ".types", @@ -167,6 +169,7 @@ def __dir__(): "EvaluationRuleReadFilterBase", "EvaluationRuleReadFilterWithKey", "EvaluationRuleStringFilterOperator", + "EvaluationRuleStringObjectFilterOperator", "EvaluatorOutputDefinition", "EvaluatorOutputDefinitionBase", "EvaluatorOutputDefinition_Boolean", diff --git a/langfuse/api/evaluation_commons/types/__init__.py b/langfuse/api/evaluation_commons/types/__init__.py index d04011e88..7c5018f54 100644 --- a/langfuse/api/evaluation_commons/types/__init__.py +++ b/langfuse/api/evaluation_commons/types/__init__.py @@ -45,6 +45,9 @@ from .evaluation_rule_string_filter_operator import ( EvaluationRuleStringFilterOperator, ) + from .evaluation_rule_string_object_filter_operator import ( + EvaluationRuleStringObjectFilterOperator, + ) from .evaluator_output_definition import ( EvaluatorOutputDefinition, EvaluatorOutputDefinition_Boolean, @@ -101,6 +104,7 @@ "EvaluationRuleReadFilterBase": ".evaluation_rule_read_filter_base", "EvaluationRuleReadFilterWithKey": ".evaluation_rule_read_filter_with_key", "EvaluationRuleStringFilterOperator": ".evaluation_rule_string_filter_operator", + "EvaluationRuleStringObjectFilterOperator": ".evaluation_rule_string_object_filter_operator", "EvaluatorOutputDefinition": ".evaluator_output_definition", "EvaluatorOutputDefinitionBase": ".evaluator_output_definition_base", "EvaluatorOutputDefinition_Boolean": ".evaluator_output_definition", @@ -183,6 +187,7 @@ def __dir__(): "EvaluationRuleReadFilterBase", "EvaluationRuleReadFilterWithKey", "EvaluationRuleStringFilterOperator", + "EvaluationRuleStringObjectFilterOperator", "EvaluatorOutputDefinition", "EvaluatorOutputDefinitionBase", "EvaluatorOutputDefinition_Boolean", diff --git a/langfuse/api/evaluation_commons/types/evaluation_rule_filter.py b/langfuse/api/evaluation_commons/types/evaluation_rule_filter.py index f281dbb17..160026a20 100644 --- a/langfuse/api/evaluation_commons/types/evaluation_rule_filter.py +++ b/langfuse/api/evaluation_commons/types/evaluation_rule_filter.py @@ -16,6 +16,9 @@ from .evaluation_rule_number_filter_operator import EvaluationRuleNumberFilterOperator from .evaluation_rule_options_filter_operator import EvaluationRuleOptionsFilterOperator from .evaluation_rule_string_filter_operator import EvaluationRuleStringFilterOperator +from .evaluation_rule_string_object_filter_operator import ( + EvaluationRuleStringObjectFilterOperator, +) class EvaluationRuleFilter_Datetime(UniversalBaseModel): @@ -34,7 +37,7 @@ class EvaluationRuleFilter_Datetime(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -97,7 +100,7 @@ class EvaluationRuleFilter_String(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -160,7 +163,7 @@ class EvaluationRuleFilter_Number(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -223,7 +226,7 @@ class EvaluationRuleFilter_StringOptions(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -286,7 +289,7 @@ class EvaluationRuleFilter_CategoryOptions(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -350,7 +353,7 @@ class EvaluationRuleFilter_ArrayOptions(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -413,7 +416,7 @@ class EvaluationRuleFilter_StringObject(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -453,7 +456,7 @@ class EvaluationRuleFilter_StringObject(UniversalBaseModel): type: typing.Literal["stringObject"] = "stringObject" column: str key: str - operator: EvaluationRuleStringFilterOperator + operator: EvaluationRuleStringObjectFilterOperator value: str model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( @@ -477,7 +480,7 @@ class EvaluationRuleFilter_NumberObject(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -541,7 +544,7 @@ class EvaluationRuleFilter_Boolean(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` @@ -604,7 +607,7 @@ class EvaluationRuleFilter_Null(UniversalBaseModel): - `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="` - `stringOptions`: `any of`, `none of` - `arrayOptions`: `any of`, `none of`, `all of` - - `stringObject`: same operators as `string` + - `stringObject`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set` (use `is set` / `is not set` for key presence; an empty value for `contains`, `starts with`, or `ends with` is treated as `is set`) - `boolean`: `"="`, `"<>"` - `null`: `is null`, `is not null` diff --git a/langfuse/api/evaluation_commons/types/evaluation_rule_string_object_filter_operator.py b/langfuse/api/evaluation_commons/types/evaluation_rule_string_object_filter_operator.py new file mode 100644 index 000000000..edf675442 --- /dev/null +++ b/langfuse/api/evaluation_commons/types/evaluation_rule_string_object_filter_operator.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core import enum + +T_Result = typing.TypeVar("T_Result") + + +class EvaluationRuleStringObjectFilterOperator(enum.StrEnum): + EQUALS = "=" + CONTAINS = "contains" + DOES_NOT_CONTAIN = "does not contain" + STARTS_WITH = "starts with" + ENDS_WITH = "ends with" + IS_SET = "is set" + IS_NOT_SET = "is not set" + + def visit( + self, + equals: typing.Callable[[], T_Result], + contains: typing.Callable[[], T_Result], + does_not_contain: typing.Callable[[], T_Result], + starts_with: typing.Callable[[], T_Result], + ends_with: typing.Callable[[], T_Result], + is_set: typing.Callable[[], T_Result], + is_not_set: typing.Callable[[], T_Result], + ) -> T_Result: + if self is EvaluationRuleStringObjectFilterOperator.EQUALS: + return equals() + if self is EvaluationRuleStringObjectFilterOperator.CONTAINS: + return contains() + if self is EvaluationRuleStringObjectFilterOperator.DOES_NOT_CONTAIN: + return does_not_contain() + if self is EvaluationRuleStringObjectFilterOperator.STARTS_WITH: + return starts_with() + if self is EvaluationRuleStringObjectFilterOperator.ENDS_WITH: + return ends_with() + if self is EvaluationRuleStringObjectFilterOperator.IS_SET: + return is_set() + if self is EvaluationRuleStringObjectFilterOperator.IS_NOT_SET: + return is_not_set() diff --git a/langfuse/api/evaluation_commons/types/string_object_evaluation_rule_filter.py b/langfuse/api/evaluation_commons/types/string_object_evaluation_rule_filter.py index d291153f0..c0aed0846 100644 --- a/langfuse/api/evaluation_commons/types/string_object_evaluation_rule_filter.py +++ b/langfuse/api/evaluation_commons/types/string_object_evaluation_rule_filter.py @@ -4,7 +4,9 @@ import pydantic from ...core.pydantic_utilities import UniversalBaseModel -from .evaluation_rule_string_filter_operator import EvaluationRuleStringFilterOperator +from .evaluation_rule_string_object_filter_operator import ( + EvaluationRuleStringObjectFilterOperator, +) class StringObjectEvaluationRuleFilter(UniversalBaseModel): @@ -18,8 +20,15 @@ class StringObjectEvaluationRuleFilter(UniversalBaseModel): Top-level key inside the object-valued column to filter on. """ - operator: EvaluationRuleStringFilterOperator - value: str + operator: EvaluationRuleStringObjectFilterOperator = pydantic.Field() + """ + Use `is set` / `is not set` to filter on key presence. + """ + + value: str = pydantic.Field() + """ + Value to compare against. Ignored for `is set` / `is not set`; send `""`. + """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/evaluators/types/cursor_meta.py b/langfuse/api/evaluators/types/cursor_meta.py index 3ee6e1a0a..7e4c64a67 100644 --- a/langfuse/api/evaluators/types/cursor_meta.py +++ b/langfuse/api/evaluators/types/cursor_meta.py @@ -3,13 +3,12 @@ import typing import pydantic -from ...core.pydantic_utilities import UniversalBaseModel +from ...utils.pagination.types.cursor_meta_response import CursorMetaResponse -class CursorMeta(UniversalBaseModel): - cursor: typing.Optional[str] = pydantic.Field(default=None) +class CursorMeta(CursorMetaResponse): """ - Opaque cursor for the next page. Omitted when there is no next page. + Cursor pagination metadata. """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/experiments/client.py b/langfuse/api/experiments/client.py index a3d469217..66a90d95b 100644 --- a/langfuse/api/experiments/client.py +++ b/langfuse/api/experiments/client.py @@ -47,7 +47,10 @@ def list( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiments on or after this datetime. + Required. Retrieve only experiments with events on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -145,7 +148,10 @@ def list_items( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiment items started on or after this datetime. + Required. Retrieve only experiment items started on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -263,7 +269,10 @@ async def list( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiments on or after this datetime. + Required. Retrieve only experiments with events on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -368,7 +377,10 @@ async def list_items( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiment items started on or after this datetime. + Required. Retrieve only experiment items started on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: diff --git a/langfuse/api/experiments/raw_client.py b/langfuse/api/experiments/raw_client.py index 8736786df..189c826ba 100644 --- a/langfuse/api/experiments/raw_client.py +++ b/langfuse/api/experiments/raw_client.py @@ -45,7 +45,10 @@ def list( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiments on or after this datetime. + Required. Retrieve only experiments with events on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -205,7 +208,10 @@ def list_items( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiment items started on or after this datetime. + Required. Retrieve only experiment items started on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -374,7 +380,10 @@ async def list( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiments on or after this datetime. + Required. Retrieve only experiments with events on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: @@ -534,7 +543,10 @@ async def list_items( Parameters ---------- from_start_time : dt.datetime - Retrieve only experiment items started on or after this datetime. + Required. Retrieve only experiment items started on or after this + datetime. A lower bound is required so the query stays fast on + large projects; pair it with an upper bound to select a specific + window. fields : typing.Optional[str] Comma-separated list of field groups to include. Available groups: diff --git a/langfuse/api/experiments/types/experiments_response_meta.py b/langfuse/api/experiments/types/experiments_response_meta.py index 038bcc45d..61f2de4ea 100644 --- a/langfuse/api/experiments/types/experiments_response_meta.py +++ b/langfuse/api/experiments/types/experiments_response_meta.py @@ -3,13 +3,12 @@ import typing import pydantic -from ...core.pydantic_utilities import UniversalBaseModel +from ...utils.pagination.types.cursor_meta_response import CursorMetaResponse -class ExperimentsResponseMeta(UniversalBaseModel): - cursor: typing.Optional[str] = pydantic.Field(default=None) +class ExperimentsResponseMeta(CursorMetaResponse): """ - Versioned base64url cursor for retrieving the next page. Absent when there are no more results. + Metadata for cursor-based pagination. """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/__init__.py b/langfuse/api/ingestion/__init__.py index 5cd4ba3bd..7a0d7c762 100644 --- a/langfuse/api/ingestion/__init__.py +++ b/langfuse/api/ingestion/__init__.py @@ -23,7 +23,6 @@ IngestionEvent_ObservationCreate, IngestionEvent_ObservationUpdate, IngestionEvent_ScoreCreate, - IngestionEvent_SdkLog, IngestionEvent_SpanCreate, IngestionEvent_SpanUpdate, IngestionEvent_TraceCreate, @@ -38,8 +37,6 @@ OptionalObservationBody, ScoreBody, ScoreEvent, - SdkLogBody, - SdkLogEvent, TraceBody, TraceEvent, UpdateEventBody, @@ -67,7 +64,6 @@ "IngestionEvent_ObservationCreate": ".types", "IngestionEvent_ObservationUpdate": ".types", "IngestionEvent_ScoreCreate": ".types", - "IngestionEvent_SdkLog": ".types", "IngestionEvent_SpanCreate": ".types", "IngestionEvent_SpanUpdate": ".types", "IngestionEvent_TraceCreate": ".types", @@ -82,8 +78,6 @@ "OptionalObservationBody": ".types", "ScoreBody": ".types", "ScoreEvent": ".types", - "SdkLogBody": ".types", - "SdkLogEvent": ".types", "TraceBody": ".types", "TraceEvent": ".types", "UpdateEventBody": ".types", @@ -140,7 +134,6 @@ def __dir__(): "IngestionEvent_ObservationCreate", "IngestionEvent_ObservationUpdate", "IngestionEvent_ScoreCreate", - "IngestionEvent_SdkLog", "IngestionEvent_SpanCreate", "IngestionEvent_SpanUpdate", "IngestionEvent_TraceCreate", @@ -155,8 +148,6 @@ def __dir__(): "OptionalObservationBody", "ScoreBody", "ScoreEvent", - "SdkLogBody", - "SdkLogEvent", "TraceBody", "TraceEvent", "UpdateEventBody", diff --git a/langfuse/api/ingestion/client.py b/langfuse/api/ingestion/client.py index c6ea6d805..4e78c2e2a 100644 --- a/langfuse/api/ingestion/client.py +++ b/langfuse/api/ingestion/client.py @@ -29,7 +29,7 @@ def with_raw_response(self) -> RawIngestionClient: return self._raw_client @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Send data via the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` instead, see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", + "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", category=None, ) def batch( @@ -42,7 +42,15 @@ def batch( """ **Legacy endpoint for batch ingestion for Langfuse Observability.** - -> Please use the OpenTelemetry endpoint (`/api/public/otel/v1/traces`). Learn more: https://langfuse.com/integrations/native/opentelemetry + This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. + + To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api + + To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry + + To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api + + The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. Within each batch, there can be multiple events. Each event has a type, an id, a timestamp, metadata and a body. @@ -59,7 +67,7 @@ def batch( Parameters ---------- batch : typing.Sequence[IngestionEvent] - Batch of tracing events to be ingested. Discriminated by attribute `type`. + Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. metadata : typing.Optional[typing.Any] Optional. Metadata field used by the Langfuse SDKs for debugging. @@ -134,7 +142,7 @@ def with_raw_response(self) -> AsyncRawIngestionClient: return self._raw_client @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Send data via the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` instead, see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", + "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", category=None, ) async def batch( @@ -147,7 +155,15 @@ async def batch( """ **Legacy endpoint for batch ingestion for Langfuse Observability.** - -> Please use the OpenTelemetry endpoint (`/api/public/otel/v1/traces`). Learn more: https://langfuse.com/integrations/native/opentelemetry + This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. + + To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api + + To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry + + To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api + + The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. Within each batch, there can be multiple events. Each event has a type, an id, a timestamp, metadata and a body. @@ -164,7 +180,7 @@ async def batch( Parameters ---------- batch : typing.Sequence[IngestionEvent] - Batch of tracing events to be ingested. Discriminated by attribute `type`. + Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. metadata : typing.Optional[typing.Any] Optional. Metadata field used by the Langfuse SDKs for debugging. diff --git a/langfuse/api/ingestion/raw_client.py b/langfuse/api/ingestion/raw_client.py index 67d22c556..95b01adb3 100644 --- a/langfuse/api/ingestion/raw_client.py +++ b/langfuse/api/ingestion/raw_client.py @@ -27,7 +27,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Send data via the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` instead, see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", + "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", category=None, ) def batch( @@ -40,7 +40,15 @@ def batch( """ **Legacy endpoint for batch ingestion for Langfuse Observability.** - -> Please use the OpenTelemetry endpoint (`/api/public/otel/v1/traces`). Learn more: https://langfuse.com/integrations/native/opentelemetry + This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. + + To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api + + To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry + + To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api + + The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. Within each batch, there can be multiple events. Each event has a type, an id, a timestamp, metadata and a body. @@ -57,7 +65,7 @@ def batch( Parameters ---------- batch : typing.Sequence[IngestionEvent] - Batch of tracing events to be ingested. Discriminated by attribute `type`. + Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. metadata : typing.Optional[typing.Any] Optional. Metadata field used by the Langfuse SDKs for debugging. @@ -167,7 +175,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Send data via the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` instead, see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", + "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", category=None, ) async def batch( @@ -180,7 +188,15 @@ async def batch( """ **Legacy endpoint for batch ingestion for Langfuse Observability.** - -> Please use the OpenTelemetry endpoint (`/api/public/otel/v1/traces`). Learn more: https://langfuse.com/integrations/native/opentelemetry + This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. + + To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api + + To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry + + To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api + + The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. Within each batch, there can be multiple events. Each event has a type, an id, a timestamp, metadata and a body. @@ -197,7 +213,7 @@ async def batch( Parameters ---------- batch : typing.Sequence[IngestionEvent] - Batch of tracing events to be ingested. Discriminated by attribute `type`. + Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. metadata : typing.Optional[typing.Any] Optional. Metadata field used by the Langfuse SDKs for debugging. diff --git a/langfuse/api/ingestion/types/__init__.py b/langfuse/api/ingestion/types/__init__.py index 4addfd9c7..4fd3b0e7e 100644 --- a/langfuse/api/ingestion/types/__init__.py +++ b/langfuse/api/ingestion/types/__init__.py @@ -23,7 +23,6 @@ IngestionEvent_ObservationCreate, IngestionEvent_ObservationUpdate, IngestionEvent_ScoreCreate, - IngestionEvent_SdkLog, IngestionEvent_SpanCreate, IngestionEvent_SpanUpdate, IngestionEvent_TraceCreate, @@ -39,8 +38,6 @@ from .optional_observation_body import OptionalObservationBody from .score_body import ScoreBody from .score_event import ScoreEvent - from .sdk_log_body import SdkLogBody - from .sdk_log_event import SdkLogEvent from .trace_body import TraceBody from .trace_event import TraceEvent from .update_event_body import UpdateEventBody @@ -67,7 +64,6 @@ "IngestionEvent_ObservationCreate": ".ingestion_event", "IngestionEvent_ObservationUpdate": ".ingestion_event", "IngestionEvent_ScoreCreate": ".ingestion_event", - "IngestionEvent_SdkLog": ".ingestion_event", "IngestionEvent_SpanCreate": ".ingestion_event", "IngestionEvent_SpanUpdate": ".ingestion_event", "IngestionEvent_TraceCreate": ".ingestion_event", @@ -82,8 +78,6 @@ "OptionalObservationBody": ".optional_observation_body", "ScoreBody": ".score_body", "ScoreEvent": ".score_event", - "SdkLogBody": ".sdk_log_body", - "SdkLogEvent": ".sdk_log_event", "TraceBody": ".trace_body", "TraceEvent": ".trace_event", "UpdateEventBody": ".update_event_body", @@ -140,7 +134,6 @@ def __dir__(): "IngestionEvent_ObservationCreate", "IngestionEvent_ObservationUpdate", "IngestionEvent_ScoreCreate", - "IngestionEvent_SdkLog", "IngestionEvent_SpanCreate", "IngestionEvent_SpanUpdate", "IngestionEvent_TraceCreate", @@ -155,8 +148,6 @@ def __dir__(): "OptionalObservationBody", "ScoreBody", "ScoreEvent", - "SdkLogBody", - "SdkLogEvent", "TraceBody", "TraceEvent", "UpdateEventBody", diff --git a/langfuse/api/ingestion/types/create_event_event.py b/langfuse/api/ingestion/types/create_event_event.py index e0cc820e1..a0b80a166 100644 --- a/langfuse/api/ingestion/types/create_event_event.py +++ b/langfuse/api/ingestion/types/create_event_event.py @@ -8,6 +8,10 @@ class CreateEventEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: CreateEventBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/create_generation_event.py b/langfuse/api/ingestion/types/create_generation_event.py index d62d6cc41..9ee974a10 100644 --- a/langfuse/api/ingestion/types/create_generation_event.py +++ b/langfuse/api/ingestion/types/create_generation_event.py @@ -8,6 +8,10 @@ class CreateGenerationEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: CreateGenerationBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/create_observation_event.py b/langfuse/api/ingestion/types/create_observation_event.py index 06d927f36..68f0c73f1 100644 --- a/langfuse/api/ingestion/types/create_observation_event.py +++ b/langfuse/api/ingestion/types/create_observation_event.py @@ -8,6 +8,10 @@ class CreateObservationEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: ObservationBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/create_span_event.py b/langfuse/api/ingestion/types/create_span_event.py index 6e60cf1fe..9b40bcf6b 100644 --- a/langfuse/api/ingestion/types/create_span_event.py +++ b/langfuse/api/ingestion/types/create_span_event.py @@ -8,6 +8,10 @@ class CreateSpanEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: CreateSpanBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/ingestion_event.py b/langfuse/api/ingestion/types/ingestion_event.py index 03202e635..3ee88e030 100644 --- a/langfuse/api/ingestion/types/ingestion_event.py +++ b/langfuse/api/ingestion/types/ingestion_event.py @@ -12,7 +12,6 @@ from .create_span_body import CreateSpanBody from .observation_body import ObservationBody from .score_body import ScoreBody -from .sdk_log_body import SdkLogBody from .trace_body import TraceBody from .update_generation_body import UpdateGenerationBody from .update_span_body import UpdateSpanBody @@ -102,18 +101,6 @@ class IngestionEvent_EventCreate(UniversalBaseModel): ) -class IngestionEvent_SdkLog(UniversalBaseModel): - type: typing.Literal["sdk-log"] = "sdk-log" - body: SdkLogBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - class IngestionEvent_ObservationCreate(UniversalBaseModel): type: typing.Literal["observation-create"] = "observation-create" body: ObservationBody @@ -147,7 +134,6 @@ class IngestionEvent_ObservationUpdate(UniversalBaseModel): IngestionEvent_GenerationCreate, IngestionEvent_GenerationUpdate, IngestionEvent_EventCreate, - IngestionEvent_SdkLog, IngestionEvent_ObservationCreate, IngestionEvent_ObservationUpdate, ], diff --git a/langfuse/api/ingestion/types/ingestion_response.py b/langfuse/api/ingestion/types/ingestion_response.py index b9781fab5..063b9a773 100644 --- a/langfuse/api/ingestion/types/ingestion_response.py +++ b/langfuse/api/ingestion/types/ingestion_response.py @@ -3,7 +3,10 @@ import typing import pydantic +import typing_extensions +from ...commons.types.deprecation import Deprecation from ...core.pydantic_utilities import UniversalBaseModel +from ...core.serialization import FieldMetadata from .ingestion_error import IngestionError from .ingestion_success import IngestionSuccess @@ -11,6 +14,9 @@ class IngestionResponse(UniversalBaseModel): successes: typing.List[IngestionSuccess] errors: typing.List[IngestionError] + deprecation: typing_extensions.Annotated[ + typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") + ] = None model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/ingestion/types/trace_event.py b/langfuse/api/ingestion/types/trace_event.py index 54127597a..3c89ec52b 100644 --- a/langfuse/api/ingestion/types/trace_event.py +++ b/langfuse/api/ingestion/types/trace_event.py @@ -8,6 +8,10 @@ class TraceEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: TraceBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/update_generation_event.py b/langfuse/api/ingestion/types/update_generation_event.py index e2c7fe284..359c0934b 100644 --- a/langfuse/api/ingestion/types/update_generation_event.py +++ b/langfuse/api/ingestion/types/update_generation_event.py @@ -8,6 +8,10 @@ class UpdateGenerationEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: UpdateGenerationBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/update_observation_event.py b/langfuse/api/ingestion/types/update_observation_event.py index 5c33e7591..0f6f8786d 100644 --- a/langfuse/api/ingestion/types/update_observation_event.py +++ b/langfuse/api/ingestion/types/update_observation_event.py @@ -8,6 +8,10 @@ class UpdateObservationEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: ObservationBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/ingestion/types/update_span_event.py b/langfuse/api/ingestion/types/update_span_event.py index 20214ac9d..a2138ced6 100644 --- a/langfuse/api/ingestion/types/update_span_event.py +++ b/langfuse/api/ingestion/types/update_span_event.py @@ -8,6 +8,10 @@ class UpdateSpanEvent(BaseEvent): + """ + Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. + """ + body: UpdateSpanBody model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/legacy/__init__.py b/langfuse/api/legacy/__init__.py index e76c96247..65afabbc8 100644 --- a/langfuse/api/legacy/__init__.py +++ b/langfuse/api/legacy/__init__.py @@ -6,17 +6,8 @@ from importlib import import_module if typing.TYPE_CHECKING: - from . import metrics_v1, observations_v1, score_v1 - from .metrics_v1 import MetricsResponse - from .observations_v1 import Observations, ObservationsViews -_dynamic_imports: typing.Dict[str, str] = { - "MetricsResponse": ".metrics_v1", - "Observations": ".observations_v1", - "ObservationsViews": ".observations_v1", - "metrics_v1": ".metrics_v1", - "observations_v1": ".observations_v1", - "score_v1": ".score_v1", -} + from . import score_v1 +_dynamic_imports: typing.Dict[str, str] = {"score_v1": ".score_v1"} def __getattr__(attr_name: str) -> typing.Any: @@ -46,14 +37,7 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = [ - "MetricsResponse", - "Observations", - "ObservationsViews", - "metrics_v1", - "observations_v1", - "score_v1", -] +__all__ = ["score_v1"] # Score-create compatibility aliases (LFE-10397). from .score_v1 import CreateScoreRequest diff --git a/langfuse/api/legacy/client.py b/langfuse/api/legacy/client.py index 3886b9eac..8e5dd2899 100644 --- a/langfuse/api/legacy/client.py +++ b/langfuse/api/legacy/client.py @@ -8,8 +8,6 @@ from .raw_client import AsyncRawLegacyClient, RawLegacyClient if typing.TYPE_CHECKING: - from .metrics_v1.client import AsyncMetricsV1Client, MetricsV1Client - from .observations_v1.client import AsyncObservationsV1Client, ObservationsV1Client from .score_v1.client import AsyncScoreV1Client, ScoreV1Client @@ -17,8 +15,6 @@ class LegacyClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._raw_client = RawLegacyClient(client_wrapper=client_wrapper) self._client_wrapper = client_wrapper - self._metrics_v1: typing.Optional[MetricsV1Client] = None - self._observations_v1: typing.Optional[ObservationsV1Client] = None self._score_v1: typing.Optional[ScoreV1Client] = None @property @@ -32,24 +28,6 @@ def with_raw_response(self) -> RawLegacyClient: """ return self._raw_client - @property - def metrics_v1(self): - if self._metrics_v1 is None: - from .metrics_v1.client import MetricsV1Client # noqa: E402 - - self._metrics_v1 = MetricsV1Client(client_wrapper=self._client_wrapper) - return self._metrics_v1 - - @property - def observations_v1(self): - if self._observations_v1 is None: - from .observations_v1.client import ObservationsV1Client # noqa: E402 - - self._observations_v1 = ObservationsV1Client( - client_wrapper=self._client_wrapper - ) - return self._observations_v1 - @property def score_v1(self): if self._score_v1 is None: @@ -63,8 +41,6 @@ class AsyncLegacyClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._raw_client = AsyncRawLegacyClient(client_wrapper=client_wrapper) self._client_wrapper = client_wrapper - self._metrics_v1: typing.Optional[AsyncMetricsV1Client] = None - self._observations_v1: typing.Optional[AsyncObservationsV1Client] = None self._score_v1: typing.Optional[AsyncScoreV1Client] = None @property @@ -78,24 +54,6 @@ def with_raw_response(self) -> AsyncRawLegacyClient: """ return self._raw_client - @property - def metrics_v1(self): - if self._metrics_v1 is None: - from .metrics_v1.client import AsyncMetricsV1Client # noqa: E402 - - self._metrics_v1 = AsyncMetricsV1Client(client_wrapper=self._client_wrapper) - return self._metrics_v1 - - @property - def observations_v1(self): - if self._observations_v1 is None: - from .observations_v1.client import AsyncObservationsV1Client # noqa: E402 - - self._observations_v1 = AsyncObservationsV1Client( - client_wrapper=self._client_wrapper - ) - return self._observations_v1 - @property def score_v1(self): if self._score_v1 is None: diff --git a/langfuse/api/legacy/metrics_v1/__init__.py b/langfuse/api/legacy/metrics_v1/__init__.py deleted file mode 100644 index fb47bc976..000000000 --- a/langfuse/api/legacy/metrics_v1/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import MetricsResponse -_dynamic_imports: typing.Dict[str, str] = {"MetricsResponse": ".types"} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["MetricsResponse"] diff --git a/langfuse/api/legacy/metrics_v1/client.py b/langfuse/api/legacy/metrics_v1/client.py deleted file mode 100644 index 696afc985..000000000 --- a/langfuse/api/legacy/metrics_v1/client.py +++ /dev/null @@ -1,223 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import typing_extensions -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.request_options import RequestOptions -from .raw_client import AsyncRawMetricsV1Client, RawMetricsV1Client -from .types.metrics_response import MetricsResponse - - -class MetricsV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawMetricsV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawMetricsV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawMetricsV1Client - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/metrics?query=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def metrics( - self, *, query: str, request_options: typing.Optional[RequestOptions] = None - ) -> MetricsResponse: - """ - Get metrics from the Langfuse project using a query object. - - Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance. - - For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api). - - Parameters - ---------- - query : str - JSON string containing the query parameters with the following structure: - ```json - { - "view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-boolean", "scores-categorical" - "dimensions": [ // Optional. Default: [] - { - "field": string // Field to group by, e.g. "name", "userId", "sessionId" - } - ], - "metrics": [ // Required. At least one metric must be provided - { - "measure": string, // What to measure, e.g. "count", "latency", "value" - "aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram" - } - ], - "filters": [ // Optional. Default: [] - { - "column": string, // Column to filter on - "operator": string, // Operator, e.g. "=", ">", "<", "contains" - "value": any, // Value to compare against - "type": string, // Data type, e.g. "string", "number", "stringObject" - "key": string // Required only when filtering on metadata - } - ], - "timeDimension": { // Optional. Default: null. If provided, results will be grouped by time - "granularity": string // One of "minute", "hour", "day", "week", "month", "auto" - }, - "fromTimestamp": string, // Required. ISO datetime string for start of time range - "toTimestamp": string, // Required. ISO datetime string for end of time range - "orderBy": [ // Optional. Default: null - { - "field": string, // Field to order by - "direction": string // "asc" or "desc" - } - ], - "config": { // Optional. Query-specific configuration - "bins": number, // Optional. Number of bins for histogram (1-100), default: 10 - "row_limit": number // Optional. Row limit for results (1-1000) - } - } - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - MetricsResponse - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.legacy.metrics_v1.metrics( - query="query", - ) - """ - _response = self._raw_client.metrics( - query=query, request_options=request_options - ) - return _response.data - - -class AsyncMetricsV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawMetricsV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawMetricsV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawMetricsV1Client - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/metrics?query=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def metrics( - self, *, query: str, request_options: typing.Optional[RequestOptions] = None - ) -> MetricsResponse: - """ - Get metrics from the Langfuse project using a query object. - - Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance. - - For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api). - - Parameters - ---------- - query : str - JSON string containing the query parameters with the following structure: - ```json - { - "view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-boolean", "scores-categorical" - "dimensions": [ // Optional. Default: [] - { - "field": string // Field to group by, e.g. "name", "userId", "sessionId" - } - ], - "metrics": [ // Required. At least one metric must be provided - { - "measure": string, // What to measure, e.g. "count", "latency", "value" - "aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram" - } - ], - "filters": [ // Optional. Default: [] - { - "column": string, // Column to filter on - "operator": string, // Operator, e.g. "=", ">", "<", "contains" - "value": any, // Value to compare against - "type": string, // Data type, e.g. "string", "number", "stringObject" - "key": string // Required only when filtering on metadata - } - ], - "timeDimension": { // Optional. Default: null. If provided, results will be grouped by time - "granularity": string // One of "minute", "hour", "day", "week", "month", "auto" - }, - "fromTimestamp": string, // Required. ISO datetime string for start of time range - "toTimestamp": string, // Required. ISO datetime string for end of time range - "orderBy": [ // Optional. Default: null - { - "field": string, // Field to order by - "direction": string // "asc" or "desc" - } - ], - "config": { // Optional. Query-specific configuration - "bins": number, // Optional. Number of bins for histogram (1-100), default: 10 - "row_limit": number // Optional. Row limit for results (1-1000) - } - } - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - MetricsResponse - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.legacy.metrics_v1.metrics( - query="query", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.metrics( - query=query, request_options=request_options - ) - return _response.data diff --git a/langfuse/api/legacy/metrics_v1/raw_client.py b/langfuse/api/legacy/metrics_v1/raw_client.py deleted file mode 100644 index 3d396981e..000000000 --- a/langfuse/api/legacy/metrics_v1/raw_client.py +++ /dev/null @@ -1,331 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing -import typing_extensions -from json.decoder import JSONDecodeError - -from ...commons.errors.access_denied_error import AccessDeniedError -from ...commons.errors.error import Error -from ...commons.errors.method_not_allowed_error import MethodNotAllowedError -from ...commons.errors.not_found_error import NotFoundError -from ...commons.errors.unauthorized_error import UnauthorizedError -from ...core.api_error import ApiError -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.http_response import AsyncHttpResponse, HttpResponse -from ...core.pydantic_utilities import parse_obj_as -from ...core.request_options import RequestOptions -from .types.metrics_response import MetricsResponse - - -class RawMetricsV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/metrics?query=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def metrics( - self, *, query: str, request_options: typing.Optional[RequestOptions] = None - ) -> HttpResponse[MetricsResponse]: - """ - Get metrics from the Langfuse project using a query object. - - Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance. - - For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api). - - Parameters - ---------- - query : str - JSON string containing the query parameters with the following structure: - ```json - { - "view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-boolean", "scores-categorical" - "dimensions": [ // Optional. Default: [] - { - "field": string // Field to group by, e.g. "name", "userId", "sessionId" - } - ], - "metrics": [ // Required. At least one metric must be provided - { - "measure": string, // What to measure, e.g. "count", "latency", "value" - "aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram" - } - ], - "filters": [ // Optional. Default: [] - { - "column": string, // Column to filter on - "operator": string, // Operator, e.g. "=", ">", "<", "contains" - "value": any, // Value to compare against - "type": string, // Data type, e.g. "string", "number", "stringObject" - "key": string // Required only when filtering on metadata - } - ], - "timeDimension": { // Optional. Default: null. If provided, results will be grouped by time - "granularity": string // One of "minute", "hour", "day", "week", "month", "auto" - }, - "fromTimestamp": string, // Required. ISO datetime string for start of time range - "toTimestamp": string, // Required. ISO datetime string for end of time range - "orderBy": [ // Optional. Default: null - { - "field": string, // Field to order by - "direction": string // "asc" or "desc" - } - ], - "config": { // Optional. Query-specific configuration - "bins": number, // Optional. Number of bins for histogram (1-100), default: 10 - "row_limit": number // Optional. Row limit for results (1-1000) - } - } - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[MetricsResponse] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/metrics", - method="GET", - params={ - "query": query, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - MetricsResponse, - parse_obj_as( - type_=MetricsResponse, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawMetricsV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/metrics?query=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def metrics( - self, *, query: str, request_options: typing.Optional[RequestOptions] = None - ) -> AsyncHttpResponse[MetricsResponse]: - """ - Get metrics from the Langfuse project using a query object. - - Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance. - - For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api). - - Parameters - ---------- - query : str - JSON string containing the query parameters with the following structure: - ```json - { - "view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-boolean", "scores-categorical" - "dimensions": [ // Optional. Default: [] - { - "field": string // Field to group by, e.g. "name", "userId", "sessionId" - } - ], - "metrics": [ // Required. At least one metric must be provided - { - "measure": string, // What to measure, e.g. "count", "latency", "value" - "aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram" - } - ], - "filters": [ // Optional. Default: [] - { - "column": string, // Column to filter on - "operator": string, // Operator, e.g. "=", ">", "<", "contains" - "value": any, // Value to compare against - "type": string, // Data type, e.g. "string", "number", "stringObject" - "key": string // Required only when filtering on metadata - } - ], - "timeDimension": { // Optional. Default: null. If provided, results will be grouped by time - "granularity": string // One of "minute", "hour", "day", "week", "month", "auto" - }, - "fromTimestamp": string, // Required. ISO datetime string for start of time range - "toTimestamp": string, // Required. ISO datetime string for end of time range - "orderBy": [ // Optional. Default: null - { - "field": string, // Field to order by - "direction": string // "asc" or "desc" - } - ], - "config": { // Optional. Query-specific configuration - "bins": number, // Optional. Number of bins for histogram (1-100), default: 10 - "row_limit": number // Optional. Row limit for results (1-1000) - } - } - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[MetricsResponse] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/metrics", - method="GET", - params={ - "query": query, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - MetricsResponse, - parse_obj_as( - type_=MetricsResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/legacy/metrics_v1/types/__init__.py b/langfuse/api/legacy/metrics_v1/types/__init__.py deleted file mode 100644 index 308847504..000000000 --- a/langfuse/api/legacy/metrics_v1/types/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .metrics_response import MetricsResponse -_dynamic_imports: typing.Dict[str, str] = {"MetricsResponse": ".metrics_response"} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["MetricsResponse"] diff --git a/langfuse/api/legacy/metrics_v1/types/metrics_response.py b/langfuse/api/legacy/metrics_v1/types/metrics_response.py deleted file mode 100644 index 546f19fd6..000000000 --- a/langfuse/api/legacy/metrics_v1/types/metrics_response.py +++ /dev/null @@ -1,26 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ....commons.types.deprecation import Deprecation -from ....core.pydantic_utilities import UniversalBaseModel -from ....core.serialization import FieldMetadata - - -class MetricsResponse(UniversalBaseModel): - data: typing.List[typing.Dict[str, typing.Any]] = pydantic.Field() - """ - The metrics data. Each item in the list contains the metric values and dimensions requested in the query. - Format varies based on the query parameters. - Histograms will return an array with [lower, upper, height] tuples. - """ - - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/legacy/observations_v1/__init__.py b/langfuse/api/legacy/observations_v1/__init__.py deleted file mode 100644 index 22b445984..000000000 --- a/langfuse/api/legacy/observations_v1/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import Observations, ObservationsViews -_dynamic_imports: typing.Dict[str, str] = { - "Observations": ".types", - "ObservationsViews": ".types", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["Observations", "ObservationsViews"] diff --git a/langfuse/api/legacy/observations_v1/client.py b/langfuse/api/legacy/observations_v1/client.py deleted file mode 100644 index 871191ee5..000000000 --- a/langfuse/api/legacy/observations_v1/client.py +++ /dev/null @@ -1,540 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import typing_extensions -from ...commons.types.observation_level import ObservationLevel -from ...commons.types.observations_view_single import ObservationsViewSingle -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.request_options import RequestOptions -from .raw_client import AsyncRawObservationsV1Client, RawObservationsV1Client -from .types.observations_views import ObservationsViews - - -class ObservationsV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawObservationsV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawObservationsV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawObservationsV1Client - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - observation_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> ObservationsViewSingle: - """ - Get a observation - - Parameters - ---------- - observation_id : str - The unique langfuse identifier of an observation, can be an event, span or generation - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ObservationsViewSingle - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.legacy.observations_v1.get( - observation_id="observationId", - ) - """ - _response = self._raw_client.get( - observation_id, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - name: typing.Optional[str] = None, - user_id: typing.Optional[str] = None, - type: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - level: typing.Optional[ObservationLevel] = None, - parent_observation_id: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - from_start_time: typing.Optional[dt.datetime] = None, - to_start_time: typing.Optional[dt.datetime] = None, - version: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> ObservationsViews: - """ - Get a list of observations. - - Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - name : typing.Optional[str] - - user_id : typing.Optional[str] - - type : typing.Optional[str] - - trace_id : typing.Optional[str] - - level : typing.Optional[ObservationLevel] - Optional filter for observations with a specific level (e.g. "DEBUG", "DEFAULT", "WARNING", "ERROR"). - - parent_observation_id : typing.Optional[str] - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for observations where the environment is one of the provided values. - - from_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time on or after this datetime (ISO 8601). - - to_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time before this datetime (ISO 8601). - - version : typing.Optional[str] - Optional filter to only include observations with a certain version. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, type, level, environment, fromStartTime, ...). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, and categoryOptions types when filtering on nested fields like metadata - } - ] - ``` - - ## Available Columns - - ### Core Observation Fields - - `id` (string) - Observation ID - - `type` (string) - Observation type (SPAN, GENERATION, EVENT) - - `name` (string) - Observation name - - `traceId` (string) - Associated trace ID - - `startTime` (datetime) - Observation start time - - `endTime` (datetime) - Observation end time - - `environment` (string) - Environment tag - - `level` (string) - Log level (DEBUG, DEFAULT, WARNING, ERROR) - - `statusMessage` (string) - Status message - - `version` (string) - Version tag - - ### Performance Metrics - - `latency` (number) - Latency in seconds (calculated: end_time - start_time) - - `timeToFirstToken` (number) - Time to first token in seconds - - `tokensPerSecond` (number) - Output tokens per second - - ### Token Usage - - `inputTokens` (number) - Number of input tokens - - `outputTokens` (number) - Number of output tokens - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - ### Cost Metrics - - `inputCost` (number) - Input cost in USD - - `outputCost` (number) - Output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Model Information - - `model` (string) - Provided model name - - `promptName` (string) - Associated prompt name - - `promptVersion` (number) - Associated prompt version - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Associated Trace Fields (requires join with traces table) - - `userId` (string) - User ID from associated trace - - `traceName` (string) - Name from associated trace - - `traceEnvironment` (string) - Environment from associated trace - - `traceTags` (arrayOptions) - Tags from associated trace - - ## Filter Examples - ```json - [ - { - "type": "string", - "column": "type", - "operator": "=", - "value": "GENERATION" - }, - { - "type": "number", - "column": "latency", - "operator": ">=", - "value": 2.5 - }, - { - "type": "stringObject", - "column": "metadata", - "key": "environment", - "operator": "=", - "value": "production" - } - ] - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ObservationsViews - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.legacy.observations_v1.get_many() - """ - _response = self._raw_client.get_many( - page=page, - limit=limit, - name=name, - user_id=user_id, - type=type, - trace_id=trace_id, - level=level, - parent_observation_id=parent_observation_id, - environment=environment, - from_start_time=from_start_time, - to_start_time=to_start_time, - version=version, - filter=filter, - request_options=request_options, - ) - return _response.data - - -class AsyncObservationsV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawObservationsV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawObservationsV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawObservationsV1Client - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - observation_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> ObservationsViewSingle: - """ - Get a observation - - Parameters - ---------- - observation_id : str - The unique langfuse identifier of an observation, can be an event, span or generation - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ObservationsViewSingle - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.legacy.observations_v1.get( - observation_id="observationId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get( - observation_id, request_options=request_options - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - name: typing.Optional[str] = None, - user_id: typing.Optional[str] = None, - type: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - level: typing.Optional[ObservationLevel] = None, - parent_observation_id: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - from_start_time: typing.Optional[dt.datetime] = None, - to_start_time: typing.Optional[dt.datetime] = None, - version: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> ObservationsViews: - """ - Get a list of observations. - - Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - name : typing.Optional[str] - - user_id : typing.Optional[str] - - type : typing.Optional[str] - - trace_id : typing.Optional[str] - - level : typing.Optional[ObservationLevel] - Optional filter for observations with a specific level (e.g. "DEBUG", "DEFAULT", "WARNING", "ERROR"). - - parent_observation_id : typing.Optional[str] - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for observations where the environment is one of the provided values. - - from_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time on or after this datetime (ISO 8601). - - to_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time before this datetime (ISO 8601). - - version : typing.Optional[str] - Optional filter to only include observations with a certain version. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, type, level, environment, fromStartTime, ...). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, and categoryOptions types when filtering on nested fields like metadata - } - ] - ``` - - ## Available Columns - - ### Core Observation Fields - - `id` (string) - Observation ID - - `type` (string) - Observation type (SPAN, GENERATION, EVENT) - - `name` (string) - Observation name - - `traceId` (string) - Associated trace ID - - `startTime` (datetime) - Observation start time - - `endTime` (datetime) - Observation end time - - `environment` (string) - Environment tag - - `level` (string) - Log level (DEBUG, DEFAULT, WARNING, ERROR) - - `statusMessage` (string) - Status message - - `version` (string) - Version tag - - ### Performance Metrics - - `latency` (number) - Latency in seconds (calculated: end_time - start_time) - - `timeToFirstToken` (number) - Time to first token in seconds - - `tokensPerSecond` (number) - Output tokens per second - - ### Token Usage - - `inputTokens` (number) - Number of input tokens - - `outputTokens` (number) - Number of output tokens - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - ### Cost Metrics - - `inputCost` (number) - Input cost in USD - - `outputCost` (number) - Output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Model Information - - `model` (string) - Provided model name - - `promptName` (string) - Associated prompt name - - `promptVersion` (number) - Associated prompt version - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Associated Trace Fields (requires join with traces table) - - `userId` (string) - User ID from associated trace - - `traceName` (string) - Name from associated trace - - `traceEnvironment` (string) - Environment from associated trace - - `traceTags` (arrayOptions) - Tags from associated trace - - ## Filter Examples - ```json - [ - { - "type": "string", - "column": "type", - "operator": "=", - "value": "GENERATION" - }, - { - "type": "number", - "column": "latency", - "operator": ">=", - "value": 2.5 - }, - { - "type": "stringObject", - "column": "metadata", - "key": "environment", - "operator": "=", - "value": "production" - } - ] - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - ObservationsViews - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.legacy.observations_v1.get_many() - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_many( - page=page, - limit=limit, - name=name, - user_id=user_id, - type=type, - trace_id=trace_id, - level=level, - parent_observation_id=parent_observation_id, - environment=environment, - from_start_time=from_start_time, - to_start_time=to_start_time, - version=version, - filter=filter, - request_options=request_options, - ) - return _response.data diff --git a/langfuse/api/legacy/observations_v1/raw_client.py b/langfuse/api/legacy/observations_v1/raw_client.py deleted file mode 100644 index 73343c130..000000000 --- a/langfuse/api/legacy/observations_v1/raw_client.py +++ /dev/null @@ -1,776 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing -import typing_extensions -from json.decoder import JSONDecodeError - -from ...commons.errors.access_denied_error import AccessDeniedError -from ...commons.errors.error import Error -from ...commons.errors.method_not_allowed_error import MethodNotAllowedError -from ...commons.errors.not_found_error import NotFoundError -from ...commons.errors.unauthorized_error import UnauthorizedError -from ...commons.types.observation_level import ObservationLevel -from ...commons.types.observations_view_single import ObservationsViewSingle -from ...core.api_error import ApiError -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.datetime_utils import serialize_datetime -from ...core.http_response import AsyncHttpResponse, HttpResponse -from ...core.jsonable_encoder import jsonable_encoder -from ...core.pydantic_utilities import parse_obj_as -from ...core.request_options import RequestOptions -from .types.observations_views import ObservationsViews - - -class RawObservationsV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - observation_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ObservationsViewSingle]: - """ - Get a observation - - Parameters - ---------- - observation_id : str - The unique langfuse identifier of an observation, can be an event, span or generation - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[ObservationsViewSingle] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/observations/{jsonable_encoder(observation_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ObservationsViewSingle, - parse_obj_as( - type_=ObservationsViewSingle, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - name: typing.Optional[str] = None, - user_id: typing.Optional[str] = None, - type: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - level: typing.Optional[ObservationLevel] = None, - parent_observation_id: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - from_start_time: typing.Optional[dt.datetime] = None, - to_start_time: typing.Optional[dt.datetime] = None, - version: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[ObservationsViews]: - """ - Get a list of observations. - - Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - name : typing.Optional[str] - - user_id : typing.Optional[str] - - type : typing.Optional[str] - - trace_id : typing.Optional[str] - - level : typing.Optional[ObservationLevel] - Optional filter for observations with a specific level (e.g. "DEBUG", "DEFAULT", "WARNING", "ERROR"). - - parent_observation_id : typing.Optional[str] - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for observations where the environment is one of the provided values. - - from_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time on or after this datetime (ISO 8601). - - to_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time before this datetime (ISO 8601). - - version : typing.Optional[str] - Optional filter to only include observations with a certain version. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, type, level, environment, fromStartTime, ...). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, and categoryOptions types when filtering on nested fields like metadata - } - ] - ``` - - ## Available Columns - - ### Core Observation Fields - - `id` (string) - Observation ID - - `type` (string) - Observation type (SPAN, GENERATION, EVENT) - - `name` (string) - Observation name - - `traceId` (string) - Associated trace ID - - `startTime` (datetime) - Observation start time - - `endTime` (datetime) - Observation end time - - `environment` (string) - Environment tag - - `level` (string) - Log level (DEBUG, DEFAULT, WARNING, ERROR) - - `statusMessage` (string) - Status message - - `version` (string) - Version tag - - ### Performance Metrics - - `latency` (number) - Latency in seconds (calculated: end_time - start_time) - - `timeToFirstToken` (number) - Time to first token in seconds - - `tokensPerSecond` (number) - Output tokens per second - - ### Token Usage - - `inputTokens` (number) - Number of input tokens - - `outputTokens` (number) - Number of output tokens - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - ### Cost Metrics - - `inputCost` (number) - Input cost in USD - - `outputCost` (number) - Output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Model Information - - `model` (string) - Provided model name - - `promptName` (string) - Associated prompt name - - `promptVersion` (number) - Associated prompt version - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Associated Trace Fields (requires join with traces table) - - `userId` (string) - User ID from associated trace - - `traceName` (string) - Name from associated trace - - `traceEnvironment` (string) - Environment from associated trace - - `traceTags` (arrayOptions) - Tags from associated trace - - ## Filter Examples - ```json - [ - { - "type": "string", - "column": "type", - "operator": "=", - "value": "GENERATION" - }, - { - "type": "number", - "column": "latency", - "operator": ">=", - "value": 2.5 - }, - { - "type": "stringObject", - "column": "metadata", - "key": "environment", - "operator": "=", - "value": "production" - } - ] - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[ObservationsViews] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/observations", - method="GET", - params={ - "page": page, - "limit": limit, - "name": name, - "userId": user_id, - "type": type, - "traceId": trace_id, - "level": level, - "parentObservationId": parent_observation_id, - "environment": environment, - "fromStartTime": serialize_datetime(from_start_time) - if from_start_time is not None - else None, - "toStartTime": serialize_datetime(to_start_time) - if to_start_time is not None - else None, - "version": version, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ObservationsViews, - parse_obj_as( - type_=ObservationsViews, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawObservationsV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - observation_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ObservationsViewSingle]: - """ - Get a observation - - Parameters - ---------- - observation_id : str - The unique langfuse identifier of an observation, can be an event, span or generation - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[ObservationsViewSingle] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/observations/{jsonable_encoder(observation_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ObservationsViewSingle, - parse_obj_as( - type_=ObservationsViewSingle, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v2/observations?fromStartTime=&toStartTime=` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - name: typing.Optional[str] = None, - user_id: typing.Optional[str] = None, - type: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - level: typing.Optional[ObservationLevel] = None, - parent_observation_id: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - from_start_time: typing.Optional[dt.datetime] = None, - to_start_time: typing.Optional[dt.datetime] = None, - version: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[ObservationsViews]: - """ - Get a list of observations. - - Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - name : typing.Optional[str] - - user_id : typing.Optional[str] - - type : typing.Optional[str] - - trace_id : typing.Optional[str] - - level : typing.Optional[ObservationLevel] - Optional filter for observations with a specific level (e.g. "DEBUG", "DEFAULT", "WARNING", "ERROR"). - - parent_observation_id : typing.Optional[str] - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for observations where the environment is one of the provided values. - - from_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time on or after this datetime (ISO 8601). - - to_start_time : typing.Optional[dt.datetime] - Retrieve only observations with a start_time before this datetime (ISO 8601). - - version : typing.Optional[str] - Optional filter to only include observations with a certain version. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, type, level, environment, fromStartTime, ...). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, and categoryOptions types when filtering on nested fields like metadata - } - ] - ``` - - ## Available Columns - - ### Core Observation Fields - - `id` (string) - Observation ID - - `type` (string) - Observation type (SPAN, GENERATION, EVENT) - - `name` (string) - Observation name - - `traceId` (string) - Associated trace ID - - `startTime` (datetime) - Observation start time - - `endTime` (datetime) - Observation end time - - `environment` (string) - Environment tag - - `level` (string) - Log level (DEBUG, DEFAULT, WARNING, ERROR) - - `statusMessage` (string) - Status message - - `version` (string) - Version tag - - ### Performance Metrics - - `latency` (number) - Latency in seconds (calculated: end_time - start_time) - - `timeToFirstToken` (number) - Time to first token in seconds - - `tokensPerSecond` (number) - Output tokens per second - - ### Token Usage - - `inputTokens` (number) - Number of input tokens - - `outputTokens` (number) - Number of output tokens - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - ### Cost Metrics - - `inputCost` (number) - Input cost in USD - - `outputCost` (number) - Output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Model Information - - `model` (string) - Provided model name - - `promptName` (string) - Associated prompt name - - `promptVersion` (number) - Associated prompt version - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Associated Trace Fields (requires join with traces table) - - `userId` (string) - User ID from associated trace - - `traceName` (string) - Name from associated trace - - `traceEnvironment` (string) - Environment from associated trace - - `traceTags` (arrayOptions) - Tags from associated trace - - ## Filter Examples - ```json - [ - { - "type": "string", - "column": "type", - "operator": "=", - "value": "GENERATION" - }, - { - "type": "number", - "column": "latency", - "operator": ">=", - "value": 2.5 - }, - { - "type": "stringObject", - "column": "metadata", - "key": "environment", - "operator": "=", - "value": "production" - } - ] - ``` - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[ObservationsViews] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/observations", - method="GET", - params={ - "page": page, - "limit": limit, - "name": name, - "userId": user_id, - "type": type, - "traceId": trace_id, - "level": level, - "parentObservationId": parent_observation_id, - "environment": environment, - "fromStartTime": serialize_datetime(from_start_time) - if from_start_time is not None - else None, - "toStartTime": serialize_datetime(to_start_time) - if to_start_time is not None - else None, - "version": version, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ObservationsViews, - parse_obj_as( - type_=ObservationsViews, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/legacy/observations_v1/types/__init__.py b/langfuse/api/legacy/observations_v1/types/__init__.py deleted file mode 100644 index 247b674a1..000000000 --- a/langfuse/api/legacy/observations_v1/types/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .observations import Observations - from .observations_views import ObservationsViews -_dynamic_imports: typing.Dict[str, str] = { - "Observations": ".observations", - "ObservationsViews": ".observations_views", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["Observations", "ObservationsViews"] diff --git a/langfuse/api/legacy/observations_v1/types/observations_views.py b/langfuse/api/legacy/observations_v1/types/observations_views.py deleted file mode 100644 index bb1da2541..000000000 --- a/langfuse/api/legacy/observations_v1/types/observations_views.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ....commons.types.deprecation import Deprecation -from ....commons.types.observations_view import ObservationsView -from ....core.pydantic_utilities import UniversalBaseModel -from ....core.serialization import FieldMetadata -from ....utils.pagination.types.meta_response import MetaResponse - - -class ObservationsViews(UniversalBaseModel): - data: typing.List[ObservationsView] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/legacy/score_v1/client.py b/langfuse/api/legacy/score_v1/client.py index 66176746b..208ccaccb 100644 --- a/langfuse/api/legacy/score_v1/client.py +++ b/langfuse/api/legacy/score_v1/client.py @@ -10,6 +10,7 @@ ScoresClient as CanonicalScoresClient, OMIT, ) +from ...scores.types.create_score_request import CreateScoreRequest from ...scores.types.create_score_response import CreateScoreResponse from ...scores.types.create_score_source import CreateScoreSource from ...core.request_options import RequestOptions @@ -51,25 +52,33 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoreResponse: """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - return CanonicalScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create( + optional_fields: typing.Dict[str, typing.Any] = { + "id": id, + "trace_id": trace_id, + "session_id": session_id, + "observation_id": observation_id, + "dataset_run_id": dataset_run_id, + "comment": comment, + "metadata": metadata, + "environment": environment, + "queue_id": queue_id, + "data_type": data_type, + "config_id": config_id, + "source": source, + } + request = CreateScoreRequest( name=name, value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, + **{ + key: field + for key, field in optional_fields.items() + if field is not OMIT + }, ) + response = CanonicalScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create(request=request, request_options=request_options) + return typing.cast(CreateScoreResponse, response) def delete( self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None @@ -144,25 +153,33 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoreResponse: """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - return await CanonicalAsyncScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create( + optional_fields: typing.Dict[str, typing.Any] = { + "id": id, + "trace_id": trace_id, + "session_id": session_id, + "observation_id": observation_id, + "dataset_run_id": dataset_run_id, + "comment": comment, + "metadata": metadata, + "environment": environment, + "queue_id": queue_id, + "data_type": data_type, + "config_id": config_id, + "source": source, + } + request = CreateScoreRequest( name=name, value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, + **{ + key: field + for key, field in optional_fields.items() + if field is not OMIT + }, ) + response = await CanonicalAsyncScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create(request=request, request_options=request_options) + return typing.cast(CreateScoreResponse, response) async def delete( self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None diff --git a/langfuse/api/metrics/client.py b/langfuse/api/metrics/client.py index f2272409b..707112cc2 100644 --- a/langfuse/api/metrics/client.py +++ b/langfuse/api/metrics/client.py @@ -29,6 +29,8 @@ def metrics( """ Get metrics from the Langfuse project using a query object. V2 endpoint with optimized performance. + Together with OpenTelemetry ingestion and Observations API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## V2 Differences - Supports `observations`, `scores-numeric`, `scores-boolean`, and `scores-categorical` views only (traces view not supported) - Direct access to tags and release fields on observations @@ -255,6 +257,8 @@ async def metrics( """ Get metrics from the Langfuse project using a query object. V2 endpoint with optimized performance. + Together with OpenTelemetry ingestion and Observations API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## V2 Differences - Supports `observations`, `scores-numeric`, `scores-boolean`, and `scores-categorical` views only (traces view not supported) - Direct access to tags and release fields on observations diff --git a/langfuse/api/metrics/raw_client.py b/langfuse/api/metrics/raw_client.py index ce52e03c2..dbb81c772 100644 --- a/langfuse/api/metrics/raw_client.py +++ b/langfuse/api/metrics/raw_client.py @@ -26,6 +26,8 @@ def metrics( """ Get metrics from the Langfuse project using a query object. V2 endpoint with optimized performance. + Together with OpenTelemetry ingestion and Observations API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## V2 Differences - Supports `observations`, `scores-numeric`, `scores-boolean`, and `scores-categorical` views only (traces view not supported) - Direct access to tags and release fields on observations @@ -306,6 +308,8 @@ async def metrics( """ Get metrics from the Langfuse project using a query object. V2 endpoint with optimized performance. + Together with OpenTelemetry ingestion and Observations API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## V2 Differences - Supports `observations`, `scores-numeric`, `scores-boolean`, and `scores-categorical` views only (traces view not supported) - Direct access to tags and release fields on observations diff --git a/langfuse/api/observations/client.py b/langfuse/api/observations/client.py index 0bab55257..5e717adb6 100644 --- a/langfuse/api/observations/client.py +++ b/langfuse/api/observations/client.py @@ -51,6 +51,8 @@ def get_many( """ Get a list of observations with cursor-based pagination and flexible field selection. + Together with OpenTelemetry ingestion and Metrics API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## Cursor-based Pagination This endpoint uses cursor-based pagination for efficient traversal of large datasets. The cursor is returned in the response metadata and should be passed in subsequent requests @@ -153,7 +155,7 @@ def get_many( // - categoryOptions: "any of", "none of" // - arrayOptions: "any of", "none of", "all of" // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches" + // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches", "is set", "is not set" // - numberObject: "=", ">", "<", ">=", "<=" // - boolean: "=", "<>" // - null: "is null", "is not null" @@ -335,6 +337,8 @@ async def get_many( """ Get a list of observations with cursor-based pagination and flexible field selection. + Together with OpenTelemetry ingestion and Metrics API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## Cursor-based Pagination This endpoint uses cursor-based pagination for efficient traversal of large datasets. The cursor is returned in the response metadata and should be passed in subsequent requests @@ -437,7 +441,7 @@ async def get_many( // - categoryOptions: "any of", "none of" // - arrayOptions: "any of", "none of", "all of" // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches" + // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches", "is set", "is not set" // - numberObject: "=", ">", "<", ">=", "<=" // - boolean: "=", "<>" // - null: "is null", "is not null" diff --git a/langfuse/api/observations/raw_client.py b/langfuse/api/observations/raw_client.py index a4d6b82e7..84dba4891 100644 --- a/langfuse/api/observations/raw_client.py +++ b/langfuse/api/observations/raw_client.py @@ -49,6 +49,8 @@ def get_many( """ Get a list of observations with cursor-based pagination and flexible field selection. + Together with OpenTelemetry ingestion and Metrics API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## Cursor-based Pagination This endpoint uses cursor-based pagination for efficient traversal of large datasets. The cursor is returned in the response metadata and should be passed in subsequent requests @@ -151,7 +153,7 @@ def get_many( // - categoryOptions: "any of", "none of" // - arrayOptions: "any of", "none of", "all of" // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches" + // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches", "is set", "is not set" // - numberObject: "=", ">", "<", ">=", "<=" // - boolean: "=", "<>" // - null: "is null", "is not null" @@ -392,6 +394,8 @@ async def get_many( """ Get a list of observations with cursor-based pagination and flexible field selection. + Together with OpenTelemetry ingestion and Metrics API v2, this is the only real-time read path. Other public API endpoints can delay data by about 10 minutes. + ## Cursor-based Pagination This endpoint uses cursor-based pagination for efficient traversal of large datasets. The cursor is returned in the response metadata and should be passed in subsequent requests @@ -494,7 +498,7 @@ async def get_many( // - categoryOptions: "any of", "none of" // - arrayOptions: "any of", "none of", "all of" // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches" + // - stringObject: "=", "contains", "does not contain", "starts with", "ends with", "matches", "is set", "is not set" // - numberObject: "=", ">", "<", ">=", "<=" // - boolean: "=", "<>" // - null: "is null", "is not null" diff --git a/langfuse/api/observations/types/observations_v2meta.py b/langfuse/api/observations/types/observations_v2meta.py index 8f86a6512..46d9f42aa 100644 --- a/langfuse/api/observations/types/observations_v2meta.py +++ b/langfuse/api/observations/types/observations_v2meta.py @@ -3,17 +3,12 @@ import typing import pydantic -from ...core.pydantic_utilities import UniversalBaseModel +from ...utils.pagination.types.cursor_meta_response import CursorMetaResponse -class ObservationsV2Meta(UniversalBaseModel): +class ObservationsV2Meta(CursorMetaResponse): """ - Metadata for cursor-based pagination - """ - - cursor: typing.Optional[str] = pydantic.Field(default=None) - """ - Base64-encoded cursor to use for retrieving the next page. If not present, there are no more results. + Metadata for cursor-based pagination. """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/opentelemetry/client.py b/langfuse/api/opentelemetry/client.py index 13177e5e6..31af9bc42 100644 --- a/langfuse/api/opentelemetry/client.py +++ b/langfuse/api/opentelemetry/client.py @@ -38,6 +38,8 @@ def export_traces( This endpoint implements the OTLP/HTTP specification for trace ingestion, providing native OpenTelemetry integration for Langfuse Observability. + Together with Observations API v2 and Metrics API v2, this is the only real-time write path. Other public API endpoints can delay data by about 10 minutes. Direct exporters must send `x-langfuse-ingestion-version: 4`; current Python and JS SDKs already do. + **Supported Formats:** - Binary Protobuf: `Content-Type: application/x-protobuf` - JSON Protobuf: `Content-Type: application/json` @@ -166,6 +168,8 @@ async def export_traces( This endpoint implements the OTLP/HTTP specification for trace ingestion, providing native OpenTelemetry integration for Langfuse Observability. + Together with Observations API v2 and Metrics API v2, this is the only real-time write path. Other public API endpoints can delay data by about 10 minutes. Direct exporters must send `x-langfuse-ingestion-version: 4`; current Python and JS SDKs already do. + **Supported Formats:** - Binary Protobuf: `Content-Type: application/x-protobuf` - JSON Protobuf: `Content-Type: application/json` diff --git a/langfuse/api/opentelemetry/raw_client.py b/langfuse/api/opentelemetry/raw_client.py index 6b68f909b..14c74c589 100644 --- a/langfuse/api/opentelemetry/raw_client.py +++ b/langfuse/api/opentelemetry/raw_client.py @@ -36,6 +36,8 @@ def export_traces( This endpoint implements the OTLP/HTTP specification for trace ingestion, providing native OpenTelemetry integration for Langfuse Observability. + Together with Observations API v2 and Metrics API v2, this is the only real-time write path. Other public API endpoints can delay data by about 10 minutes. Direct exporters must send `x-langfuse-ingestion-version: 4`; current Python and JS SDKs already do. + **Supported Formats:** - Binary Protobuf: `Content-Type: application/x-protobuf` - JSON Protobuf: `Content-Type: application/json` @@ -171,6 +173,8 @@ async def export_traces( This endpoint implements the OTLP/HTTP specification for trace ingestion, providing native OpenTelemetry integration for Langfuse Observability. + Together with Observations API v2 and Metrics API v2, this is the only real-time write path. Other public API endpoints can delay data by about 10 minutes. Direct exporters must send `x-langfuse-ingestion-version: 4`; current Python and JS SDKs already do. + **Supported Formats:** - Binary Protobuf: `Content-Type: application/x-protobuf` - JSON Protobuf: `Content-Type: application/json` diff --git a/langfuse/api/organizations/types/organization_api_key.py b/langfuse/api/organizations/types/organization_api_key.py index 572015ab3..5454ac2d8 100644 --- a/langfuse/api/organizations/types/organization_api_key.py +++ b/langfuse/api/organizations/types/organization_api_key.py @@ -16,11 +16,24 @@ class OrganizationApiKey(UniversalBaseModel): ] expires_at: typing_extensions.Annotated[ typing.Optional[dt.datetime], FieldMetadata(alias="expiresAt") - ] = None + ] = pydantic.Field(default=None) + """ + Expiration timestamp. Null if the key does not expire. + """ + last_used_at: typing_extensions.Annotated[ typing.Optional[dt.datetime], FieldMetadata(alias="lastUsedAt") ] = None - note: typing.Optional[str] = None + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Name of the API key. Contains the same value as note; null if no name was provided. + """ + + note: typing.Optional[str] = pydantic.Field(default=None) + """ + Deprecated alias for name. Contains the same value as name. + """ + public_key: typing_extensions.Annotated[str, FieldMetadata(alias="publicKey")] display_secret_key: typing_extensions.Annotated[ str, FieldMetadata(alias="displaySecretKey") diff --git a/langfuse/api/projects/client.py b/langfuse/api/projects/client.py index 52350f3b6..35d9683b1 100644 --- a/langfuse/api/projects/client.py +++ b/langfuse/api/projects/client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper @@ -260,7 +261,9 @@ def create_api_key( self, project_id: str, *, + name: typing.Optional[str] = OMIT, note: typing.Optional[str] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, public_key: typing.Optional[str] = OMIT, secret_key: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, @@ -272,8 +275,14 @@ def create_api_key( ---------- project_id : str + name : typing.Optional[str] + Optional name for the API key. Cannot be provided together with note, even if either value is an empty string. + note : typing.Optional[str] - Optional note for the API key + Deprecated alias for name. Cannot be provided together with name, even if either value is an empty string. + + expires_at : typing.Optional[dt.datetime] + Optional expiration timestamp in ISO 8601 format. Must be in the future. Omit or set to null for a key that does not expire. public_key : typing.Optional[str] Optional predefined public key. Must start with 'pk-lf-'. If provided, secretKey must also be provided. @@ -306,7 +315,9 @@ def create_api_key( """ _response = self._raw_client.create_api_key( project_id, + name=name, note=note, + expires_at=expires_at, public_key=public_key, secret_key=secret_key, request_options=request_options, @@ -645,7 +656,9 @@ async def create_api_key( self, project_id: str, *, + name: typing.Optional[str] = OMIT, note: typing.Optional[str] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, public_key: typing.Optional[str] = OMIT, secret_key: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, @@ -657,8 +670,14 @@ async def create_api_key( ---------- project_id : str + name : typing.Optional[str] + Optional name for the API key. Cannot be provided together with note, even if either value is an empty string. + note : typing.Optional[str] - Optional note for the API key + Deprecated alias for name. Cannot be provided together with name, even if either value is an empty string. + + expires_at : typing.Optional[dt.datetime] + Optional expiration timestamp in ISO 8601 format. Must be in the future. Omit or set to null for a key that does not expire. public_key : typing.Optional[str] Optional predefined public key. Must start with 'pk-lf-'. If provided, secretKey must also be provided. @@ -699,7 +718,9 @@ async def main() -> None: """ _response = await self._raw_client.create_api_key( project_id, + name=name, note=note, + expires_at=expires_at, public_key=public_key, secret_key=secret_key, request_options=request_options, diff --git a/langfuse/api/projects/raw_client.py b/langfuse/api/projects/raw_client.py index 524f37d3f..71457a2ac 100644 --- a/langfuse/api/projects/raw_client.py +++ b/langfuse/api/projects/raw_client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from json.decoder import JSONDecodeError @@ -577,7 +578,9 @@ def create_api_key( self, project_id: str, *, + name: typing.Optional[str] = OMIT, note: typing.Optional[str] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, public_key: typing.Optional[str] = OMIT, secret_key: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, @@ -589,8 +592,14 @@ def create_api_key( ---------- project_id : str + name : typing.Optional[str] + Optional name for the API key. Cannot be provided together with note, even if either value is an empty string. + note : typing.Optional[str] - Optional note for the API key + Deprecated alias for name. Cannot be provided together with name, even if either value is an empty string. + + expires_at : typing.Optional[dt.datetime] + Optional expiration timestamp in ISO 8601 format. Must be in the future. Omit or set to null for a key that does not expire. public_key : typing.Optional[str] Optional predefined public key. Must start with 'pk-lf-'. If provided, secretKey must also be provided. @@ -609,7 +618,9 @@ def create_api_key( f"api/public/projects/{jsonable_encoder(project_id)}/apiKeys", method="POST", json={ + "name": name, "note": note, + "expiresAt": expires_at, "publicKey": public_key, "secretKey": secret_key, }, @@ -1353,7 +1364,9 @@ async def create_api_key( self, project_id: str, *, + name: typing.Optional[str] = OMIT, note: typing.Optional[str] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, public_key: typing.Optional[str] = OMIT, secret_key: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, @@ -1365,8 +1378,14 @@ async def create_api_key( ---------- project_id : str + name : typing.Optional[str] + Optional name for the API key. Cannot be provided together with note, even if either value is an empty string. + note : typing.Optional[str] - Optional note for the API key + Deprecated alias for name. Cannot be provided together with name, even if either value is an empty string. + + expires_at : typing.Optional[dt.datetime] + Optional expiration timestamp in ISO 8601 format. Must be in the future. Omit or set to null for a key that does not expire. public_key : typing.Optional[str] Optional predefined public key. Must start with 'pk-lf-'. If provided, secretKey must also be provided. @@ -1385,7 +1404,9 @@ async def create_api_key( f"api/public/projects/{jsonable_encoder(project_id)}/apiKeys", method="POST", json={ + "name": name, "note": note, + "expiresAt": expires_at, "publicKey": public_key, "secretKey": secret_key, }, diff --git a/langfuse/api/projects/types/api_key_response.py b/langfuse/api/projects/types/api_key_response.py index 06ad54610..e3a67cab4 100644 --- a/langfuse/api/projects/types/api_key_response.py +++ b/langfuse/api/projects/types/api_key_response.py @@ -18,12 +18,27 @@ class ApiKeyResponse(UniversalBaseModel): created_at: typing_extensions.Annotated[ dt.datetime, FieldMetadata(alias="createdAt") ] + expires_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], FieldMetadata(alias="expiresAt") + ] = pydantic.Field(default=None) + """ + Expiration timestamp. Null if the key does not expire. + """ + public_key: typing_extensions.Annotated[str, FieldMetadata(alias="publicKey")] secret_key: typing_extensions.Annotated[str, FieldMetadata(alias="secretKey")] display_secret_key: typing_extensions.Annotated[ str, FieldMetadata(alias="displaySecretKey") ] - note: typing.Optional[str] = None + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Name of the API key. Contains the same value as note; null if no name was provided. + """ + + note: typing.Optional[str] = pydantic.Field(default=None) + """ + Deprecated alias for name. Contains the same value as name. + """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/projects/types/api_key_summary.py b/langfuse/api/projects/types/api_key_summary.py index 68d21421b..d64cd1045 100644 --- a/langfuse/api/projects/types/api_key_summary.py +++ b/langfuse/api/projects/types/api_key_summary.py @@ -20,11 +20,24 @@ class ApiKeySummary(UniversalBaseModel): ] expires_at: typing_extensions.Annotated[ typing.Optional[dt.datetime], FieldMetadata(alias="expiresAt") - ] = None + ] = pydantic.Field(default=None) + """ + Expiration timestamp. Null if the key does not expire. + """ + last_used_at: typing_extensions.Annotated[ typing.Optional[dt.datetime], FieldMetadata(alias="lastUsedAt") ] = None - note: typing.Optional[str] = None + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Name of the API key. Contains the same value as note; null if no name was provided. + """ + + note: typing.Optional[str] = pydantic.Field(default=None) + """ + Deprecated alias for name. Contains the same value as name. + """ + public_key: typing_extensions.Annotated[str, FieldMetadata(alias="publicKey")] display_secret_key: typing_extensions.Annotated[ str, FieldMetadata(alias="displaySecretKey") diff --git a/langfuse/api/prompts/client.py b/langfuse/api/prompts/client.py index eff5e572f..b4a978c44 100644 --- a/langfuse/api/prompts/client.py +++ b/langfuse/api/prompts/client.py @@ -98,6 +98,7 @@ def list( limit: typing.Optional[int] = None, from_updated_at: typing.Optional[dt.datetime] = None, to_updated_at: typing.Optional[dt.datetime] = None, + filter: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> PromptMetaListResponse: """ @@ -123,6 +124,32 @@ def list( to_updated_at : typing.Optional[dt.datetime] Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601) + filter : typing.Optional[str] + JSON string containing an array of filter conditions, combined with AND. + When provided, this takes precedence over name, version, + label, tag, fromUpdatedAt, and toUpdatedAt. An empty array applies no + filters. Project scoping and pagination always apply. + + Each condition requires `type`, `column`, `operator`, and `value`. + Supported types and columns: + - `string`: `id`, `name`, `type`. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is not empty`. + - `stringOptions`: `id`, `name`, `type`. Operators: `any of`, `none of`. Value: non-empty string array. + - `number`: `version`. Operators: `=`, `>`, `<`, `>=`, `<=`. Value: number. + - `datetime`: `createdAt`, `updatedAt`. Operators: `>`, `<`, `>=`, `<=`. Value: ISO 8601 timestamp. + - `arrayOptions`: `labels`, `tags`. Operators: `any of`, `all of`, `none of`. Value: non-empty string array. + - `stringObject`: `config`. Requires an additional `key` field. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set`. Value: string (use an empty string for key-presence operators). + + String equality is case-sensitive. String search operators use + case-insensitive SQL pattern matching: `%` matches any sequence and + `_` matches a single character. A backslash escapes these characters; + JSON-escape the backslash in the filter value. Asterisks are literal. + For config, an empty search value is treated as `is set`. + + Filters apply to prompt versions before aggregation: returned versions, + labels, and lastConfig reflect only matching versions. + + Example: [{"type":"string","column":"name","operator":"starts with","value":"tools/"},{"type":"string","column":"name","operator":"ends with","value":"/description"}] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -152,6 +179,7 @@ def list( limit=limit, from_updated_at=from_updated_at, to_updated_at=to_updated_at, + filter=filter, request_options=request_options, ) return _response.data @@ -358,6 +386,7 @@ async def list( limit: typing.Optional[int] = None, from_updated_at: typing.Optional[dt.datetime] = None, to_updated_at: typing.Optional[dt.datetime] = None, + filter: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> PromptMetaListResponse: """ @@ -383,6 +412,32 @@ async def list( to_updated_at : typing.Optional[dt.datetime] Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601) + filter : typing.Optional[str] + JSON string containing an array of filter conditions, combined with AND. + When provided, this takes precedence over name, version, + label, tag, fromUpdatedAt, and toUpdatedAt. An empty array applies no + filters. Project scoping and pagination always apply. + + Each condition requires `type`, `column`, `operator`, and `value`. + Supported types and columns: + - `string`: `id`, `name`, `type`. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is not empty`. + - `stringOptions`: `id`, `name`, `type`. Operators: `any of`, `none of`. Value: non-empty string array. + - `number`: `version`. Operators: `=`, `>`, `<`, `>=`, `<=`. Value: number. + - `datetime`: `createdAt`, `updatedAt`. Operators: `>`, `<`, `>=`, `<=`. Value: ISO 8601 timestamp. + - `arrayOptions`: `labels`, `tags`. Operators: `any of`, `all of`, `none of`. Value: non-empty string array. + - `stringObject`: `config`. Requires an additional `key` field. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set`. Value: string (use an empty string for key-presence operators). + + String equality is case-sensitive. String search operators use + case-insensitive SQL pattern matching: `%` matches any sequence and + `_` matches a single character. A backslash escapes these characters; + JSON-escape the backslash in the filter value. Asterisks are literal. + For config, an empty search value is treated as `is set`. + + Filters apply to prompt versions before aggregation: returned versions, + labels, and lastConfig reflect only matching versions. + + Example: [{"type":"string","column":"name","operator":"starts with","value":"tools/"},{"type":"string","column":"name","operator":"ends with","value":"/description"}] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -420,6 +475,7 @@ async def main() -> None: limit=limit, from_updated_at=from_updated_at, to_updated_at=to_updated_at, + filter=filter, request_options=request_options, ) return _response.data diff --git a/langfuse/api/prompts/raw_client.py b/langfuse/api/prompts/raw_client.py index a2d81a9d1..c4816cbe0 100644 --- a/langfuse/api/prompts/raw_client.py +++ b/langfuse/api/prompts/raw_client.py @@ -161,6 +161,7 @@ def list( limit: typing.Optional[int] = None, from_updated_at: typing.Optional[dt.datetime] = None, to_updated_at: typing.Optional[dt.datetime] = None, + filter: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[PromptMetaListResponse]: """ @@ -186,6 +187,32 @@ def list( to_updated_at : typing.Optional[dt.datetime] Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601) + filter : typing.Optional[str] + JSON string containing an array of filter conditions, combined with AND. + When provided, this takes precedence over name, version, + label, tag, fromUpdatedAt, and toUpdatedAt. An empty array applies no + filters. Project scoping and pagination always apply. + + Each condition requires `type`, `column`, `operator`, and `value`. + Supported types and columns: + - `string`: `id`, `name`, `type`. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is not empty`. + - `stringOptions`: `id`, `name`, `type`. Operators: `any of`, `none of`. Value: non-empty string array. + - `number`: `version`. Operators: `=`, `>`, `<`, `>=`, `<=`. Value: number. + - `datetime`: `createdAt`, `updatedAt`. Operators: `>`, `<`, `>=`, `<=`. Value: ISO 8601 timestamp. + - `arrayOptions`: `labels`, `tags`. Operators: `any of`, `all of`, `none of`. Value: non-empty string array. + - `stringObject`: `config`. Requires an additional `key` field. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set`. Value: string (use an empty string for key-presence operators). + + String equality is case-sensitive. String search operators use + case-insensitive SQL pattern matching: `%` matches any sequence and + `_` matches a single character. A backslash escapes these characters; + JSON-escape the backslash in the filter value. Asterisks are literal. + For config, an empty search value is treated as `is set`. + + Filters apply to prompt versions before aggregation: returned versions, + labels, and lastConfig reflect only matching versions. + + Example: [{"type":"string","column":"name","operator":"starts with","value":"tools/"},{"type":"string","column":"name","operator":"ends with","value":"/description"}] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -208,6 +235,7 @@ def list( "toUpdatedAt": serialize_datetime(to_updated_at) if to_updated_at is not None else None, + "filter": filter, }, request_options=request_options, ) @@ -642,6 +670,7 @@ async def list( limit: typing.Optional[int] = None, from_updated_at: typing.Optional[dt.datetime] = None, to_updated_at: typing.Optional[dt.datetime] = None, + filter: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[PromptMetaListResponse]: """ @@ -667,6 +696,32 @@ async def list( to_updated_at : typing.Optional[dt.datetime] Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601) + filter : typing.Optional[str] + JSON string containing an array of filter conditions, combined with AND. + When provided, this takes precedence over name, version, + label, tag, fromUpdatedAt, and toUpdatedAt. An empty array applies no + filters. Project scoping and pagination always apply. + + Each condition requires `type`, `column`, `operator`, and `value`. + Supported types and columns: + - `string`: `id`, `name`, `type`. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is not empty`. + - `stringOptions`: `id`, `name`, `type`. Operators: `any of`, `none of`. Value: non-empty string array. + - `number`: `version`. Operators: `=`, `>`, `<`, `>=`, `<=`. Value: number. + - `datetime`: `createdAt`, `updatedAt`. Operators: `>`, `<`, `>=`, `<=`. Value: ISO 8601 timestamp. + - `arrayOptions`: `labels`, `tags`. Operators: `any of`, `all of`, `none of`. Value: non-empty string array. + - `stringObject`: `config`. Requires an additional `key` field. Operators: `=`, `contains`, `does not contain`, `starts with`, `ends with`, `is set`, `is not set`. Value: string (use an empty string for key-presence operators). + + String equality is case-sensitive. String search operators use + case-insensitive SQL pattern matching: `%` matches any sequence and + `_` matches a single character. A backslash escapes these characters; + JSON-escape the backslash in the filter value. Asterisks are literal. + For config, an empty search value is treated as `is set`. + + Filters apply to prompt versions before aggregation: returned versions, + labels, and lastConfig reflect only matching versions. + + Example: [{"type":"string","column":"name","operator":"starts with","value":"tools/"},{"type":"string","column":"name","operator":"ends with","value":"/description"}] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -689,6 +744,7 @@ async def list( "toUpdatedAt": serialize_datetime(to_updated_at) if to_updated_at is not None else None, + "filter": filter, }, request_options=request_options, ) diff --git a/langfuse/api/scores/__init__.py b/langfuse/api/scores/__init__.py index 9b006eddd..5af5bdfb8 100644 --- a/langfuse/api/scores/__init__.py +++ b/langfuse/api/scores/__init__.py @@ -7,40 +7,26 @@ if typing.TYPE_CHECKING: from .types import ( + CreateScoreBatchError, + CreateScoreBatchRequest, + CreateScoreBatchResponse, + CreateScoreBatchResults, CreateScoreRequest, CreateScoreResponse, CreateScoreSource, - GetScoresResponse, - GetScoresResponseData, - GetScoresResponseDataBoolean, - GetScoresResponseDataCategorical, - GetScoresResponseDataCorrection, - GetScoresResponseDataNumeric, - GetScoresResponseDataText, - GetScoresResponseData_Boolean, - GetScoresResponseData_Categorical, - GetScoresResponseData_Correction, - GetScoresResponseData_Numeric, - GetScoresResponseData_Text, - GetScoresResponseTraceData, + CreateScoresRequest, + CreateScoresResponse, ) _dynamic_imports: typing.Dict[str, str] = { + "CreateScoreBatchError": ".types", + "CreateScoreBatchRequest": ".types", + "CreateScoreBatchResponse": ".types", + "CreateScoreBatchResults": ".types", "CreateScoreRequest": ".types", "CreateScoreResponse": ".types", "CreateScoreSource": ".types", - "GetScoresResponse": ".types", - "GetScoresResponseData": ".types", - "GetScoresResponseDataBoolean": ".types", - "GetScoresResponseDataCategorical": ".types", - "GetScoresResponseDataCorrection": ".types", - "GetScoresResponseDataNumeric": ".types", - "GetScoresResponseDataText": ".types", - "GetScoresResponseData_Boolean": ".types", - "GetScoresResponseData_Categorical": ".types", - "GetScoresResponseData_Correction": ".types", - "GetScoresResponseData_Numeric": ".types", - "GetScoresResponseData_Text": ".types", - "GetScoresResponseTraceData": ".types", + "CreateScoresRequest": ".types", + "CreateScoresResponse": ".types", } @@ -72,20 +58,13 @@ def __dir__(): __all__ = [ + "CreateScoreBatchError", + "CreateScoreBatchRequest", + "CreateScoreBatchResponse", + "CreateScoreBatchResults", "CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource", - "GetScoresResponse", - "GetScoresResponseData", - "GetScoresResponseDataBoolean", - "GetScoresResponseDataCategorical", - "GetScoresResponseDataCorrection", - "GetScoresResponseDataNumeric", - "GetScoresResponseDataText", - "GetScoresResponseData_Boolean", - "GetScoresResponseData_Categorical", - "GetScoresResponseData_Correction", - "GetScoresResponseData_Numeric", - "GetScoresResponseData_Text", - "GetScoresResponseTraceData", + "CreateScoresRequest", + "CreateScoresResponse", ] diff --git a/langfuse/api/scores/client.py b/langfuse/api/scores/client.py index d035dc0bd..6698c6ccf 100644 --- a/langfuse/api/scores/client.py +++ b/langfuse/api/scores/client.py @@ -1,19 +1,12 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing -import typing_extensions -from ..commons.types.create_score_value import CreateScoreValue -from ..commons.types.score import Score -from ..commons.types.score_data_type import ScoreDataType -from ..commons.types.score_source import ScoreSource from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from .raw_client import AsyncRawScoresClient, RawScoresClient -from .types.create_score_response import CreateScoreResponse -from .types.create_score_source import CreateScoreSource -from .types.get_scores_response import GetScoresResponse +from .types.create_scores_request import CreateScoresRequest +from .types.create_scores_response import CreateScoresResponse # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -37,71 +30,27 @@ def with_raw_response(self) -> RawScoresClient: def create( self, *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, + request: CreateScoresRequest, request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: + ) -> CreateScoresResponse: """ - Create a score (supports trace, observation, session, and dataset run scores) + Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. Parameters ---------- - name : str - - value : CreateScoreValue - The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters. - - id : typing.Optional[str] - - trace_id : typing.Optional[str] - - session_id : typing.Optional[str] - - observation_id : typing.Optional[str] - - dataset_run_id : typing.Optional[str] - - comment : typing.Optional[str] - - metadata : typing.Optional[typing.Dict[str, typing.Any]] - - environment : typing.Optional[str] - The environment of the score. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - - queue_id : typing.Optional[str] - The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue. - - data_type : typing.Optional[ScoreDataType] - The data type of the score. When passing a configId this field is inferred. Otherwise, this field must be passed or will default to numeric. - - config_id : typing.Optional[str] - Reference a score config on a score. The unique langfuse identifier of a score config. When passing this field, the dataType and stringValue fields are automatically populated. - - source : typing.Optional[CreateScoreSource] - The source of the score. Defaults to API. Set to ANNOTATION to prefill scores (e.g. from an LLM) for a human reviewer to verify in an annotation queue. When source is ANNOTATION, a configId is required unless dataType is CORRECTION. EVAL is reserved for internal evaluator outputs and is not accepted on this endpoint. + request : CreateScoresRequest request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - CreateScoreResponse + CreateScoresResponse Examples -------- from langfuse import LangfuseAPI + from langfuse.scores import CreateScoreRequest client = LangfuseAPI( x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", @@ -112,214 +61,14 @@ def create( base_url="https://yourhost.com/path/to/api", ) client.scores.create( - name="name", - value=1.1, + request=CreateScoreRequest( + name="name", + value=1.1, + ), ) """ _response = self._raw_client.create( - name=name, - value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - source: typing.Optional[ScoreSource] = None, - operator: typing.Optional[str] = None, - value: typing.Optional[float] = None, - score_ids: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - dataset_run_id: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - data_type: typing.Optional[ScoreDataType] = None, - trace_tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> GetScoresResponse: - """ - Get a list of scores (supports both trace and session scores) - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. Maximum 100. Defaults to 50. Requests with a limit greater than 100 return HTTP 400. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - Retrieve only scores with this userId associated to the trace. - - name : typing.Optional[str] - Retrieve only scores with this name. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for scores where the environment is one of the provided values. - - source : typing.Optional[ScoreSource] - Retrieve only scores from a specific source. - - operator : typing.Optional[str] - Retrieve only scores with value. - - value : typing.Optional[float] - Retrieve only scores with value. - - score_ids : typing.Optional[str] - Comma-separated list of score IDs to limit the results to. - - config_id : typing.Optional[str] - Retrieve only scores with a specific configId. - - session_id : typing.Optional[str] - Retrieve only scores with a specific sessionId. - - dataset_run_id : typing.Optional[str] - Retrieve only scores with a specific datasetRunId. - - trace_id : typing.Optional[str] - Retrieve only scores with a specific traceId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter scores by. - - queue_id : typing.Optional[str] - Retrieve only scores with a specific annotation queueId. - - data_type : typing.Optional[ScoreDataType] - Retrieve only scores with a specific dataType. - - trace_tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only scores linked to traces that include all of these tags will be returned. - - fields : typing.Optional[str] - Comma-separated list of field groups to include in the response. Available field groups: 'score' (core score fields), 'trace' (trace properties: userId, tags, environment, sessionId). If not specified, both 'score' and 'trace' are returned by default. Example: 'score' to exclude trace data, 'score,trace' to include both. Note: When filtering by trace properties (using userId or traceTags parameters), the 'trace' field group must be included, otherwise a 400 error will be returned. - - filter : typing.Optional[str] - A JSON stringified array of filter objects. Each object requires type, column, operator, and value. Supports filtering by score metadata using the stringObject type. Example: [{"type":"stringObject","column":"metadata","key":"user_id","operator":"=","value":"abc123"}]. Supported types: stringObject (metadata key-value filtering), string, number, datetime, stringOptions, arrayOptions. Supported operators for stringObject: =, contains, does not contain, starts with, ends with. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - GetScoresResponse - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.scores.get_many() - """ - _response = self._raw_client.get_many( - page=page, - limit=limit, - user_id=user_id, - name=name, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - environment=environment, - source=source, - operator=operator, - value=value, - score_ids=score_ids, - config_id=config_id, - session_id=session_id, - dataset_run_id=dataset_run_id, - trace_id=trace_id, - observation_id=observation_id, - queue_id=queue_id, - data_type=data_type, - trace_tags=trace_tags, - fields=fields, - filter=filter, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` with the `id` filter instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_by_id( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> Score: - """ - Get a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - Score - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.scores.get_by_id( - score_id="scoreId", - ) - """ - _response = self._raw_client.get_by_id( - score_id, request_options=request_options + request=request, request_options=request_options ) return _response.data @@ -342,73 +91,29 @@ def with_raw_response(self) -> AsyncRawScoresClient: async def create( self, *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, + request: CreateScoresRequest, request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: + ) -> CreateScoresResponse: """ - Create a score (supports trace, observation, session, and dataset run scores) + Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. Parameters ---------- - name : str - - value : CreateScoreValue - The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters. - - id : typing.Optional[str] - - trace_id : typing.Optional[str] - - session_id : typing.Optional[str] - - observation_id : typing.Optional[str] - - dataset_run_id : typing.Optional[str] - - comment : typing.Optional[str] - - metadata : typing.Optional[typing.Dict[str, typing.Any]] - - environment : typing.Optional[str] - The environment of the score. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - - queue_id : typing.Optional[str] - The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue. - - data_type : typing.Optional[ScoreDataType] - The data type of the score. When passing a configId this field is inferred. Otherwise, this field must be passed or will default to numeric. - - config_id : typing.Optional[str] - Reference a score config on a score. The unique langfuse identifier of a score config. When passing this field, the dataType and stringValue fields are automatically populated. - - source : typing.Optional[CreateScoreSource] - The source of the score. Defaults to API. Set to ANNOTATION to prefill scores (e.g. from an LLM) for a human reviewer to verify in an annotation queue. When source is ANNOTATION, a configId is required unless dataType is CORRECTION. EVAL is reserved for internal evaluator outputs and is not accepted on this endpoint. + request : CreateScoresRequest request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - CreateScoreResponse + CreateScoresResponse Examples -------- import asyncio from langfuse import AsyncLangfuseAPI + from langfuse.scores import CreateScoreRequest client = AsyncLangfuseAPI( x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", @@ -422,232 +127,16 @@ async def create( async def main() -> None: await client.scores.create( - name="name", - value=1.1, + request=CreateScoreRequest( + name="name", + value=1.1, + ), ) asyncio.run(main()) """ _response = await self._raw_client.create( - name=name, - value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - source: typing.Optional[ScoreSource] = None, - operator: typing.Optional[str] = None, - value: typing.Optional[float] = None, - score_ids: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - dataset_run_id: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - data_type: typing.Optional[ScoreDataType] = None, - trace_tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> GetScoresResponse: - """ - Get a list of scores (supports both trace and session scores) - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. Maximum 100. Defaults to 50. Requests with a limit greater than 100 return HTTP 400. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - Retrieve only scores with this userId associated to the trace. - - name : typing.Optional[str] - Retrieve only scores with this name. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for scores where the environment is one of the provided values. - - source : typing.Optional[ScoreSource] - Retrieve only scores from a specific source. - - operator : typing.Optional[str] - Retrieve only scores with value. - - value : typing.Optional[float] - Retrieve only scores with value. - - score_ids : typing.Optional[str] - Comma-separated list of score IDs to limit the results to. - - config_id : typing.Optional[str] - Retrieve only scores with a specific configId. - - session_id : typing.Optional[str] - Retrieve only scores with a specific sessionId. - - dataset_run_id : typing.Optional[str] - Retrieve only scores with a specific datasetRunId. - - trace_id : typing.Optional[str] - Retrieve only scores with a specific traceId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter scores by. - - queue_id : typing.Optional[str] - Retrieve only scores with a specific annotation queueId. - - data_type : typing.Optional[ScoreDataType] - Retrieve only scores with a specific dataType. - - trace_tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only scores linked to traces that include all of these tags will be returned. - - fields : typing.Optional[str] - Comma-separated list of field groups to include in the response. Available field groups: 'score' (core score fields), 'trace' (trace properties: userId, tags, environment, sessionId). If not specified, both 'score' and 'trace' are returned by default. Example: 'score' to exclude trace data, 'score,trace' to include both. Note: When filtering by trace properties (using userId or traceTags parameters), the 'trace' field group must be included, otherwise a 400 error will be returned. - - filter : typing.Optional[str] - A JSON stringified array of filter objects. Each object requires type, column, operator, and value. Supports filtering by score metadata using the stringObject type. Example: [{"type":"stringObject","column":"metadata","key":"user_id","operator":"=","value":"abc123"}]. Supported types: stringObject (metadata key-value filtering), string, number, datetime, stringOptions, arrayOptions. Supported operators for stringObject: =, contains, does not contain, starts with, ends with. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - GetScoresResponse - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.scores.get_many() - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_many( - page=page, - limit=limit, - user_id=user_id, - name=name, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - environment=environment, - source=source, - operator=operator, - value=value, - score_ids=score_ids, - config_id=config_id, - session_id=session_id, - dataset_run_id=dataset_run_id, - trace_id=trace_id, - observation_id=observation_id, - queue_id=queue_id, - data_type=data_type, - trace_tags=trace_tags, - fields=fields, - filter=filter, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` with the `id` filter instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_by_id( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> Score: - """ - Get a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - Score - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.scores.get_by_id( - score_id="scoreId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_by_id( - score_id, request_options=request_options + request=request, request_options=request_options ) return _response.data diff --git a/langfuse/api/scores/raw_client.py b/langfuse/api/scores/raw_client.py index b87521d7a..09a77b575 100644 --- a/langfuse/api/scores/raw_client.py +++ b/langfuse/api/scores/raw_client.py @@ -1,8 +1,6 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing -import typing_extensions from json.decoder import JSONDecodeError from ..commons.errors.access_denied_error import AccessDeniedError @@ -10,21 +8,14 @@ from ..commons.errors.method_not_allowed_error import MethodNotAllowedError from ..commons.errors.not_found_error import NotFoundError from ..commons.errors.unauthorized_error import UnauthorizedError -from ..commons.types.create_score_value import CreateScoreValue -from ..commons.types.score import Score -from ..commons.types.score_data_type import ScoreDataType -from ..commons.types.score_source import ScoreSource from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions from ..core.serialization import convert_and_respect_annotation_metadata -from .types.create_score_response import CreateScoreResponse -from .types.create_score_source import CreateScoreSource -from .types.get_scores_response import GetScoresResponse +from .types.create_scores_request import CreateScoresRequest +from .types.create_scores_response import CreateScoresResponse # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -37,418 +28,38 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): def create( self, *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, + request: CreateScoresRequest, request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[CreateScoreResponse]: + ) -> HttpResponse[CreateScoresResponse]: """ - Create a score (supports trace, observation, session, and dataset run scores) + Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. Parameters ---------- - name : str - - value : CreateScoreValue - The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters. - - id : typing.Optional[str] - - trace_id : typing.Optional[str] - - session_id : typing.Optional[str] - - observation_id : typing.Optional[str] - - dataset_run_id : typing.Optional[str] - - comment : typing.Optional[str] - - metadata : typing.Optional[typing.Dict[str, typing.Any]] - - environment : typing.Optional[str] - The environment of the score. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - - queue_id : typing.Optional[str] - The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue. - - data_type : typing.Optional[ScoreDataType] - The data type of the score. When passing a configId this field is inferred. Otherwise, this field must be passed or will default to numeric. - - config_id : typing.Optional[str] - Reference a score config on a score. The unique langfuse identifier of a score config. When passing this field, the dataType and stringValue fields are automatically populated. - - source : typing.Optional[CreateScoreSource] - The source of the score. Defaults to API. Set to ANNOTATION to prefill scores (e.g. from an LLM) for a human reviewer to verify in an annotation queue. When source is ANNOTATION, a configId is required unless dataType is CORRECTION. EVAL is reserved for internal evaluator outputs and is not accepted on this endpoint. + request : CreateScoresRequest request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - HttpResponse[CreateScoreResponse] + HttpResponse[CreateScoresResponse] """ _response = self._client_wrapper.httpx_client.request( "api/public/scores", method="POST", - json={ - "id": id, - "traceId": trace_id, - "sessionId": session_id, - "observationId": observation_id, - "datasetRunId": dataset_run_id, - "name": name, - "value": convert_and_respect_annotation_metadata( - object_=value, annotation=CreateScoreValue, direction="write" - ), - "comment": comment, - "metadata": metadata, - "environment": environment, - "queueId": queue_id, - "dataType": data_type, - "configId": config_id, - "source": source, - }, + json=convert_and_respect_annotation_metadata( + object_=request, annotation=CreateScoresRequest, direction="write" + ), request_options=request_options, omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - CreateScoreResponse, - parse_obj_as( - type_=CreateScoreResponse, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - source: typing.Optional[ScoreSource] = None, - operator: typing.Optional[str] = None, - value: typing.Optional[float] = None, - score_ids: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - dataset_run_id: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - data_type: typing.Optional[ScoreDataType] = None, - trace_tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[GetScoresResponse]: - """ - Get a list of scores (supports both trace and session scores) - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. Maximum 100. Defaults to 50. Requests with a limit greater than 100 return HTTP 400. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - Retrieve only scores with this userId associated to the trace. - - name : typing.Optional[str] - Retrieve only scores with this name. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for scores where the environment is one of the provided values. - - source : typing.Optional[ScoreSource] - Retrieve only scores from a specific source. - - operator : typing.Optional[str] - Retrieve only scores with value. - - value : typing.Optional[float] - Retrieve only scores with value. - - score_ids : typing.Optional[str] - Comma-separated list of score IDs to limit the results to. - - config_id : typing.Optional[str] - Retrieve only scores with a specific configId. - - session_id : typing.Optional[str] - Retrieve only scores with a specific sessionId. - - dataset_run_id : typing.Optional[str] - Retrieve only scores with a specific datasetRunId. - - trace_id : typing.Optional[str] - Retrieve only scores with a specific traceId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter scores by. - - queue_id : typing.Optional[str] - Retrieve only scores with a specific annotation queueId. - - data_type : typing.Optional[ScoreDataType] - Retrieve only scores with a specific dataType. - - trace_tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only scores linked to traces that include all of these tags will be returned. - - fields : typing.Optional[str] - Comma-separated list of field groups to include in the response. Available field groups: 'score' (core score fields), 'trace' (trace properties: userId, tags, environment, sessionId). If not specified, both 'score' and 'trace' are returned by default. Example: 'score' to exclude trace data, 'score,trace' to include both. Note: When filtering by trace properties (using userId or traceTags parameters), the 'trace' field group must be included, otherwise a 400 error will be returned. - - filter : typing.Optional[str] - A JSON stringified array of filter objects. Each object requires type, column, operator, and value. Supports filtering by score metadata using the stringObject type. Example: [{"type":"stringObject","column":"metadata","key":"user_id","operator":"=","value":"abc123"}]. Supported types: stringObject (metadata key-value filtering), string, number, datetime, stringOptions, arrayOptions. Supported operators for stringObject: =, contains, does not contain, starts with, ends with. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[GetScoresResponse] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/v2/scores", - method="GET", - params={ - "page": page, - "limit": limit, - "userId": user_id, - "name": name, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "environment": environment, - "source": source, - "operator": operator, - "value": value, - "scoreIds": score_ids, - "configId": config_id, - "sessionId": session_id, - "datasetRunId": dataset_run_id, - "traceId": trace_id, - "observationId": observation_id, - "queueId": queue_id, - "dataType": data_type, - "traceTags": trace_tags, - "fields": fields, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - GetScoresResponse, - parse_obj_as( - type_=GetScoresResponse, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` with the `id` filter instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get_by_id( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> HttpResponse[Score]: - """ - Get a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[Score] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/v2/scores/{jsonable_encoder(score_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - Score, + CreateScoresResponse, parse_obj_as( - type_=Score, # type: ignore + type_=CreateScoresResponse, # type: ignore object_=_response.json(), ), ) @@ -529,418 +140,38 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): async def create( self, *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, + request: CreateScoresRequest, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[CreateScoreResponse]: + ) -> AsyncHttpResponse[CreateScoresResponse]: """ - Create a score (supports trace, observation, session, and dataset run scores) + Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. Parameters ---------- - name : str - - value : CreateScoreValue - The value of the score. Must be passed as string for categorical and text scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false). Text score values must be between 1 and 500 characters. - - id : typing.Optional[str] - - trace_id : typing.Optional[str] - - session_id : typing.Optional[str] - - observation_id : typing.Optional[str] - - dataset_run_id : typing.Optional[str] - - comment : typing.Optional[str] - - metadata : typing.Optional[typing.Dict[str, typing.Any]] - - environment : typing.Optional[str] - The environment of the score. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'. - - queue_id : typing.Optional[str] - The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue. - - data_type : typing.Optional[ScoreDataType] - The data type of the score. When passing a configId this field is inferred. Otherwise, this field must be passed or will default to numeric. - - config_id : typing.Optional[str] - Reference a score config on a score. The unique langfuse identifier of a score config. When passing this field, the dataType and stringValue fields are automatically populated. - - source : typing.Optional[CreateScoreSource] - The source of the score. Defaults to API. Set to ANNOTATION to prefill scores (e.g. from an LLM) for a human reviewer to verify in an annotation queue. When source is ANNOTATION, a configId is required unless dataType is CORRECTION. EVAL is reserved for internal evaluator outputs and is not accepted on this endpoint. + request : CreateScoresRequest request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[CreateScoreResponse] + AsyncHttpResponse[CreateScoresResponse] """ _response = await self._client_wrapper.httpx_client.request( "api/public/scores", method="POST", - json={ - "id": id, - "traceId": trace_id, - "sessionId": session_id, - "observationId": observation_id, - "datasetRunId": dataset_run_id, - "name": name, - "value": convert_and_respect_annotation_metadata( - object_=value, annotation=CreateScoreValue, direction="write" - ), - "comment": comment, - "metadata": metadata, - "environment": environment, - "queueId": queue_id, - "dataType": data_type, - "configId": config_id, - "source": source, - }, + json=convert_and_respect_annotation_metadata( + object_=request, annotation=CreateScoresRequest, direction="write" + ), request_options=request_options, omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - CreateScoreResponse, - parse_obj_as( - type_=CreateScoreResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_many( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - source: typing.Optional[ScoreSource] = None, - operator: typing.Optional[str] = None, - value: typing.Optional[float] = None, - score_ids: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - dataset_run_id: typing.Optional[str] = None, - trace_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - data_type: typing.Optional[ScoreDataType] = None, - trace_tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[GetScoresResponse]: - """ - Get a list of scores (supports both trace and session scores) - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1. - - limit : typing.Optional[int] - Limit of items per page. Maximum 100. Defaults to 50. Requests with a limit greater than 100 return HTTP 400. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - Retrieve only scores with this userId associated to the trace. - - name : typing.Optional[str] - Retrieve only scores with this name. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include scores created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for scores where the environment is one of the provided values. - - source : typing.Optional[ScoreSource] - Retrieve only scores from a specific source. - - operator : typing.Optional[str] - Retrieve only scores with value. - - value : typing.Optional[float] - Retrieve only scores with value. - - score_ids : typing.Optional[str] - Comma-separated list of score IDs to limit the results to. - - config_id : typing.Optional[str] - Retrieve only scores with a specific configId. - - session_id : typing.Optional[str] - Retrieve only scores with a specific sessionId. - - dataset_run_id : typing.Optional[str] - Retrieve only scores with a specific datasetRunId. - - trace_id : typing.Optional[str] - Retrieve only scores with a specific traceId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter scores by. - - queue_id : typing.Optional[str] - Retrieve only scores with a specific annotation queueId. - - data_type : typing.Optional[ScoreDataType] - Retrieve only scores with a specific dataType. - - trace_tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only scores linked to traces that include all of these tags will be returned. - - fields : typing.Optional[str] - Comma-separated list of field groups to include in the response. Available field groups: 'score' (core score fields), 'trace' (trace properties: userId, tags, environment, sessionId). If not specified, both 'score' and 'trace' are returned by default. Example: 'score' to exclude trace data, 'score,trace' to include both. Note: When filtering by trace properties (using userId or traceTags parameters), the 'trace' field group must be included, otherwise a 400 error will be returned. - - filter : typing.Optional[str] - A JSON stringified array of filter objects. Each object requires type, column, operator, and value. Supports filtering by score metadata using the stringObject type. Example: [{"type":"stringObject","column":"metadata","key":"user_id","operator":"=","value":"abc123"}]. Supported types: stringObject (metadata key-value filtering), string, number, datetime, stringOptions, arrayOptions. Supported operators for stringObject: =, contains, does not contain, starts with, ends with. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[GetScoresResponse] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/v2/scores", - method="GET", - params={ - "page": page, - "limit": limit, - "userId": user_id, - "name": name, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "environment": environment, - "source": source, - "operator": operator, - "value": value, - "scoreIds": score_ids, - "configId": config_id, - "sessionId": session_id, - "datasetRunId": dataset_run_id, - "traceId": trace_id, - "observationId": observation_id, - "queueId": queue_id, - "dataType": data_type, - "traceTags": trace_tags, - "fields": fields, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - GetScoresResponse, - parse_obj_as( - type_=GetScoresResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. Use `GET /api/public/v3/scores` with the `id` filter instead. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get_by_id( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> AsyncHttpResponse[Score]: - """ - Get a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[Score] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/v2/scores/{jsonable_encoder(score_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - Score, + CreateScoresResponse, parse_obj_as( - type_=Score, # type: ignore + type_=CreateScoresResponse, # type: ignore object_=_response.json(), ), ) diff --git a/langfuse/api/scores/types/__init__.py b/langfuse/api/scores/types/__init__.py index 754c497f4..067d8fb27 100644 --- a/langfuse/api/scores/types/__init__.py +++ b/langfuse/api/scores/types/__init__.py @@ -6,41 +6,25 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .create_score_batch_error import CreateScoreBatchError + from .create_score_batch_request import CreateScoreBatchRequest + from .create_score_batch_response import CreateScoreBatchResponse + from .create_score_batch_results import CreateScoreBatchResults from .create_score_request import CreateScoreRequest from .create_score_response import CreateScoreResponse from .create_score_source import CreateScoreSource - from .get_scores_response import GetScoresResponse - from .get_scores_response_data import ( - GetScoresResponseData, - GetScoresResponseData_Boolean, - GetScoresResponseData_Categorical, - GetScoresResponseData_Correction, - GetScoresResponseData_Numeric, - GetScoresResponseData_Text, - ) - from .get_scores_response_data_boolean import GetScoresResponseDataBoolean - from .get_scores_response_data_categorical import GetScoresResponseDataCategorical - from .get_scores_response_data_correction import GetScoresResponseDataCorrection - from .get_scores_response_data_numeric import GetScoresResponseDataNumeric - from .get_scores_response_data_text import GetScoresResponseDataText - from .get_scores_response_trace_data import GetScoresResponseTraceData + from .create_scores_request import CreateScoresRequest + from .create_scores_response import CreateScoresResponse _dynamic_imports: typing.Dict[str, str] = { + "CreateScoreBatchError": ".create_score_batch_error", + "CreateScoreBatchRequest": ".create_score_batch_request", + "CreateScoreBatchResponse": ".create_score_batch_response", + "CreateScoreBatchResults": ".create_score_batch_results", "CreateScoreRequest": ".create_score_request", "CreateScoreResponse": ".create_score_response", "CreateScoreSource": ".create_score_source", - "GetScoresResponse": ".get_scores_response", - "GetScoresResponseData": ".get_scores_response_data", - "GetScoresResponseDataBoolean": ".get_scores_response_data_boolean", - "GetScoresResponseDataCategorical": ".get_scores_response_data_categorical", - "GetScoresResponseDataCorrection": ".get_scores_response_data_correction", - "GetScoresResponseDataNumeric": ".get_scores_response_data_numeric", - "GetScoresResponseDataText": ".get_scores_response_data_text", - "GetScoresResponseData_Boolean": ".get_scores_response_data", - "GetScoresResponseData_Categorical": ".get_scores_response_data", - "GetScoresResponseData_Correction": ".get_scores_response_data", - "GetScoresResponseData_Numeric": ".get_scores_response_data", - "GetScoresResponseData_Text": ".get_scores_response_data", - "GetScoresResponseTraceData": ".get_scores_response_trace_data", + "CreateScoresRequest": ".create_scores_request", + "CreateScoresResponse": ".create_scores_response", } @@ -72,20 +56,13 @@ def __dir__(): __all__ = [ + "CreateScoreBatchError", + "CreateScoreBatchRequest", + "CreateScoreBatchResponse", + "CreateScoreBatchResults", "CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource", - "GetScoresResponse", - "GetScoresResponseData", - "GetScoresResponseDataBoolean", - "GetScoresResponseDataCategorical", - "GetScoresResponseDataCorrection", - "GetScoresResponseDataNumeric", - "GetScoresResponseDataText", - "GetScoresResponseData_Boolean", - "GetScoresResponseData_Categorical", - "GetScoresResponseData_Correction", - "GetScoresResponseData_Numeric", - "GetScoresResponseData_Text", - "GetScoresResponseTraceData", + "CreateScoresRequest", + "CreateScoresResponse", ] diff --git a/langfuse/api/datasets/types/delete_dataset_run_response.py b/langfuse/api/scores/types/create_score_batch_error.py similarity index 84% rename from langfuse/api/datasets/types/delete_dataset_run_response.py rename to langfuse/api/scores/types/create_score_batch_error.py index 024433ef6..a34b11a24 100644 --- a/langfuse/api/datasets/types/delete_dataset_run_response.py +++ b/langfuse/api/scores/types/create_score_batch_error.py @@ -6,7 +6,7 @@ from ...core.pydantic_utilities import UniversalBaseModel -class DeleteDatasetRunResponse(UniversalBaseModel): +class CreateScoreBatchError(UniversalBaseModel): message: str model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/langfuse/api/scores/types/create_score_batch_request.py b/langfuse/api/scores/types/create_score_batch_request.py new file mode 100644 index 000000000..47ab94b9b --- /dev/null +++ b/langfuse/api/scores/types/create_score_batch_request.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .create_score_request import CreateScoreRequest + +""" +from langfuse.commons import ScoreDataType +from langfuse.scores import CreateScoreRequest + +[ + CreateScoreRequest( + name="accuracy", + value=0.9, + trace_id="cdef-1234-5678-90ab", + ), + CreateScoreRequest( + name="helpfulness", + value=1.0, + data_type=ScoreDataType.BOOLEAN, + trace_id="cdef-1234-5678-90ab", + ), +] +""" +CreateScoreBatchRequest = typing.List[CreateScoreRequest] diff --git a/langfuse/api/ingestion/types/sdk_log_body.py b/langfuse/api/scores/types/create_score_batch_response.py similarity index 64% rename from langfuse/api/ingestion/types/sdk_log_body.py rename to langfuse/api/scores/types/create_score_batch_response.py index d5b46f118..b68766fa8 100644 --- a/langfuse/api/ingestion/types/sdk_log_body.py +++ b/langfuse/api/scores/types/create_score_batch_response.py @@ -6,8 +6,12 @@ from ...core.pydantic_utilities import UniversalBaseModel -class SdkLogBody(UniversalBaseModel): - log: typing.Any +class CreateScoreBatchResponse(UniversalBaseModel): + """ + Batch accepted (HTTP 202). + """ + + message: typing.Literal["Accepted"] = "Accepted" model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/scores/types/create_score_batch_results.py b/langfuse/api/scores/types/create_score_batch_results.py new file mode 100644 index 000000000..063291b9e --- /dev/null +++ b/langfuse/api/scores/types/create_score_batch_results.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import UniversalBaseModel +from .create_score_batch_error import CreateScoreBatchError + + +class CreateScoreBatchResults(UniversalBaseModel): + """ + Acceptance counts and errors (HTTP 207). + """ + + accepted: int + rejected: int + errors: typing.List[CreateScoreBatchError] + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/scores/types/create_scores_request.py b/langfuse/api/scores/types/create_scores_request.py new file mode 100644 index 000000000..2acfce283 --- /dev/null +++ b/langfuse/api/scores/types/create_scores_request.py @@ -0,0 +1,8 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .create_score_batch_request import CreateScoreBatchRequest +from .create_score_request import CreateScoreRequest + +CreateScoresRequest = typing.Union[CreateScoreRequest, CreateScoreBatchRequest] diff --git a/langfuse/api/scores/types/create_scores_response.py b/langfuse/api/scores/types/create_scores_response.py new file mode 100644 index 000000000..8cf007796 --- /dev/null +++ b/langfuse/api/scores/types/create_scores_response.py @@ -0,0 +1,11 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .create_score_batch_response import CreateScoreBatchResponse +from .create_score_batch_results import CreateScoreBatchResults +from .create_score_response import CreateScoreResponse + +CreateScoresResponse = typing.Union[ + CreateScoreResponse, CreateScoreBatchResponse, CreateScoreBatchResults +] diff --git a/langfuse/api/scores/types/get_scores_response.py b/langfuse/api/scores/types/get_scores_response.py deleted file mode 100644 index 70f9c7a51..000000000 --- a/langfuse/api/scores/types/get_scores_response.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.deprecation import Deprecation -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from ...utils.pagination.types.meta_response import MetaResponse -from .get_scores_response_data import GetScoresResponseData - - -class GetScoresResponse(UniversalBaseModel): - data: typing.List[GetScoresResponseData] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_data.py b/langfuse/api/scores/types/get_scores_response_data.py deleted file mode 100644 index f85cd471c..000000000 --- a/langfuse/api/scores/types/get_scores_response_data.py +++ /dev/null @@ -1,258 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -from __future__ import annotations - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...commons.types.score_source import ScoreSource -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseData_Numeric(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["NUMERIC"], FieldMetadata(alias="dataType") - ] = "NUMERIC" - trace: typing.Optional[GetScoresResponseTraceData] = None - value: float - id: str - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - dataset_run_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="datasetRunId") - ] = None - name: str - source: ScoreSource - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class GetScoresResponseData_Categorical(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["CATEGORICAL"], FieldMetadata(alias="dataType") - ] = "CATEGORICAL" - trace: typing.Optional[GetScoresResponseTraceData] = None - value: float - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - dataset_run_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="datasetRunId") - ] = None - name: str - source: ScoreSource - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class GetScoresResponseData_Boolean(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["BOOLEAN"], FieldMetadata(alias="dataType") - ] = "BOOLEAN" - trace: typing.Optional[GetScoresResponseTraceData] = None - value: float - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - dataset_run_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="datasetRunId") - ] = None - name: str - source: ScoreSource - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class GetScoresResponseData_Correction(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["CORRECTION"], FieldMetadata(alias="dataType") - ] = "CORRECTION" - trace: typing.Optional[GetScoresResponseTraceData] = None - value: float - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - dataset_run_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="datasetRunId") - ] = None - name: str - source: ScoreSource - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class GetScoresResponseData_Text(UniversalBaseModel): - data_type: typing_extensions.Annotated[ - typing.Literal["TEXT"], FieldMetadata(alias="dataType") - ] = "TEXT" - trace: typing.Optional[GetScoresResponseTraceData] = None - string_value: typing_extensions.Annotated[str, FieldMetadata(alias="stringValue")] - id: str - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="observationId") - ] = None - dataset_run_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="datasetRunId") - ] = None - name: str - source: ScoreSource - timestamp: dt.datetime - created_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="createdAt") - ] - updated_at: typing_extensions.Annotated[ - dt.datetime, FieldMetadata(alias="updatedAt") - ] - author_user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="authorUserId") - ] = None - comment: typing.Optional[str] = None - metadata: typing.Any - config_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="configId") - ] = None - queue_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="queueId") - ] = None - environment: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -GetScoresResponseData = typing_extensions.Annotated[ - typing.Union[ - GetScoresResponseData_Numeric, - GetScoresResponseData_Categorical, - GetScoresResponseData_Boolean, - GetScoresResponseData_Correction, - GetScoresResponseData_Text, - ], - pydantic.Field(discriminator="data_type"), -] diff --git a/langfuse/api/scores/types/get_scores_response_data_boolean.py b/langfuse/api/scores/types/get_scores_response_data_boolean.py deleted file mode 100644 index eee645cd3..000000000 --- a/langfuse/api/scores/types/get_scores_response_data_boolean.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...commons.types.boolean_score import BooleanScore -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseDataBoolean(BooleanScore): - trace: typing.Optional[GetScoresResponseTraceData] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_data_categorical.py b/langfuse/api/scores/types/get_scores_response_data_categorical.py deleted file mode 100644 index edd4e46fa..000000000 --- a/langfuse/api/scores/types/get_scores_response_data_categorical.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...commons.types.categorical_score import CategoricalScore -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseDataCategorical(CategoricalScore): - trace: typing.Optional[GetScoresResponseTraceData] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_data_correction.py b/langfuse/api/scores/types/get_scores_response_data_correction.py deleted file mode 100644 index df39c65ed..000000000 --- a/langfuse/api/scores/types/get_scores_response_data_correction.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...commons.types.correction_score import CorrectionScore -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseDataCorrection(CorrectionScore): - trace: typing.Optional[GetScoresResponseTraceData] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_data_numeric.py b/langfuse/api/scores/types/get_scores_response_data_numeric.py deleted file mode 100644 index 350be0e0c..000000000 --- a/langfuse/api/scores/types/get_scores_response_data_numeric.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...commons.types.numeric_score import NumericScore -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseDataNumeric(NumericScore): - trace: typing.Optional[GetScoresResponseTraceData] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_data_text.py b/langfuse/api/scores/types/get_scores_response_data_text.py deleted file mode 100644 index 4949afe74..000000000 --- a/langfuse/api/scores/types/get_scores_response_data_text.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...commons.types.text_score import TextScore -from .get_scores_response_trace_data import GetScoresResponseTraceData - - -class GetScoresResponseDataText(TextScore): - trace: typing.Optional[GetScoresResponseTraceData] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/get_scores_response_trace_data.py b/langfuse/api/scores/types/get_scores_response_trace_data.py deleted file mode 100644 index 306aaaf78..000000000 --- a/langfuse/api/scores/types/get_scores_response_trace_data.py +++ /dev/null @@ -1,38 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class GetScoresResponseTraceData(UniversalBaseModel): - user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="userId") - ] = pydantic.Field(default=None) - """ - The user ID associated with the trace referenced by score - """ - - tags: typing.Optional[typing.List[str]] = pydantic.Field(default=None) - """ - A list of tags associated with the trace referenced by score - """ - - environment: typing.Optional[str] = pydantic.Field(default=None) - """ - The environment of the trace referenced by score - """ - - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = pydantic.Field(default=None) - """ - The session ID associated with the trace referenced by score - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores_v3/types/get_scores_v3meta.py b/langfuse/api/scores_v3/types/get_scores_v3meta.py index 7dfcfe0e1..832793364 100644 --- a/langfuse/api/scores_v3/types/get_scores_v3meta.py +++ b/langfuse/api/scores_v3/types/get_scores_v3meta.py @@ -3,15 +3,11 @@ import typing import pydantic -from ...core.pydantic_utilities import UniversalBaseModel +from ...utils.pagination.types.cursor_meta_response import CursorMetaResponse -class GetScoresV3Meta(UniversalBaseModel): +class GetScoresV3Meta(CursorMetaResponse): limit: int - cursor: typing.Optional[str] = pydantic.Field(default=None) - """ - URL-safe base64 (base64url) cursor for the next page. Absent when there are no more results. - """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/sessions/__init__.py b/langfuse/api/sessions/__init__.py deleted file mode 100644 index 89b7e63bc..000000000 --- a/langfuse/api/sessions/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import PaginatedSessions -_dynamic_imports: typing.Dict[str, str] = {"PaginatedSessions": ".types"} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["PaginatedSessions"] diff --git a/langfuse/api/sessions/client.py b/langfuse/api/sessions/client.py deleted file mode 100644 index d485c29ed..000000000 --- a/langfuse/api/sessions/client.py +++ /dev/null @@ -1,299 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import typing_extensions -from ..commons.types.session_with_traces import SessionWithTraces -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.request_options import RequestOptions -from .raw_client import AsyncRawSessionsClient, RawSessionsClient -from .types.paginated_sessions import PaginatedSessions - - -class SessionsClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawSessionsClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawSessionsClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawSessionsClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedSessions: - """ - Get sessions. - - This legacy endpoint is not recommended for new data extraction workflows. - Use the v2 observations endpoint with a bounded time range and group rows by - `sessionId` instead: - `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for sessions where the environment is one of the provided values. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedSessions - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.sessions.list() - """ - _response = self._raw_client.list( - page=page, - limit=limit, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - environment=environment, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - session_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> SessionWithTraces: - """ - Get a session. - - Please note that `traces` on this endpoint are not paginated. For large - sessions or new data extraction workflows, use the v2 observations endpoint - with a URL-encoded `sessionId` filter and a bounded time range: - `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. - - Parameters - ---------- - session_id : str - The unique id of a session - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - SessionWithTraces - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.sessions.get( - session_id="sessionId", - ) - """ - _response = self._raw_client.get(session_id, request_options=request_options) - return _response.data - - -class AsyncSessionsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawSessionsClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawSessionsClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawSessionsClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> PaginatedSessions: - """ - Get sessions. - - This legacy endpoint is not recommended for new data extraction workflows. - Use the v2 observations endpoint with a bounded time range and group rows by - `sessionId` instead: - `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for sessions where the environment is one of the provided values. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - PaginatedSessions - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.sessions.list() - - - asyncio.run(main()) - """ - _response = await self._raw_client.list( - page=page, - limit=limit, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - environment=environment, - request_options=request_options, - ) - return _response.data - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - session_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> SessionWithTraces: - """ - Get a session. - - Please note that `traces` on this endpoint are not paginated. For large - sessions or new data extraction workflows, use the v2 observations endpoint - with a URL-encoded `sessionId` filter and a bounded time range: - `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. - - Parameters - ---------- - session_id : str - The unique id of a session - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - SessionWithTraces - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.sessions.get( - session_id="sessionId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get( - session_id, request_options=request_options - ) - return _response.data diff --git a/langfuse/api/sessions/raw_client.py b/langfuse/api/sessions/raw_client.py deleted file mode 100644 index 9e27d41e4..000000000 --- a/langfuse/api/sessions/raw_client.py +++ /dev/null @@ -1,537 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing -import typing_extensions -from json.decoder import JSONDecodeError - -from ..commons.errors.access_denied_error import AccessDeniedError -from ..commons.errors.error import Error -from ..commons.errors.method_not_allowed_error import MethodNotAllowedError -from ..commons.errors.not_found_error import NotFoundError -from ..commons.errors.unauthorized_error import UnauthorizedError -from ..commons.types.session_with_traces import SessionWithTraces -from ..core.api_error import ApiError -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime -from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.jsonable_encoder import jsonable_encoder -from ..core.pydantic_utilities import parse_obj_as -from ..core.request_options import RequestOptions -from .types.paginated_sessions import PaginatedSessions - - -class RawSessionsClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[PaginatedSessions]: - """ - Get sessions. - - This legacy endpoint is not recommended for new data extraction workflows. - Use the v2 observations endpoint with a bounded time range and group rows by - `sessionId` instead: - `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for sessions where the environment is one of the provided values. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[PaginatedSessions] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/sessions", - method="GET", - params={ - "page": page, - "limit": limit, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "environment": environment, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedSessions, - parse_obj_as( - type_=PaginatedSessions, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - session_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[SessionWithTraces]: - """ - Get a session. - - Please note that `traces` on this endpoint are not paginated. For large - sessions or new data extraction workflows, use the v2 observations endpoint - with a URL-encoded `sessionId` filter and a bounded time range: - `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. - - Parameters - ---------- - session_id : str - The unique id of a session - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[SessionWithTraces] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/sessions/{jsonable_encoder(session_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - SessionWithTraces, - parse_obj_as( - type_=SessionWithTraces, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawSessionsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[PaginatedSessions]: - """ - Get sessions. - - This legacy endpoint is not recommended for new data extraction workflows. - Use the v2 observations endpoint with a bounded time range and group rows by - `sessionId` instead: - `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include sessions created before a certain datetime (ISO 8601) - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for sessions where the environment is one of the provided values. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[PaginatedSessions] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/sessions", - method="GET", - params={ - "page": page, - "limit": limit, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "environment": environment, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - PaginatedSessions, - parse_obj_as( - type_=PaginatedSessions, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read session data via `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - session_id: str, - *, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[SessionWithTraces]: - """ - Get a session. - - Please note that `traces` on this endpoint are not paginated. For large - sessions or new data extraction workflows, use the v2 observations endpoint - with a URL-encoded `sessionId` filter and a bounded time range: - `GET /api/public/v2/observations?filter=&fromStartTime=&toStartTime=`. - - Parameters - ---------- - session_id : str - The unique id of a session - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[SessionWithTraces] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/sessions/{jsonable_encoder(session_id)}", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - SessionWithTraces, - parse_obj_as( - type_=SessionWithTraces, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/sessions/types/__init__.py b/langfuse/api/sessions/types/__init__.py deleted file mode 100644 index 45f21e139..000000000 --- a/langfuse/api/sessions/types/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .paginated_sessions import PaginatedSessions -_dynamic_imports: typing.Dict[str, str] = {"PaginatedSessions": ".paginated_sessions"} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["PaginatedSessions"] diff --git a/langfuse/api/sessions/types/paginated_sessions.py b/langfuse/api/sessions/types/paginated_sessions.py deleted file mode 100644 index cc1ef68b3..000000000 --- a/langfuse/api/sessions/types/paginated_sessions.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.deprecation import Deprecation -from ...commons.types.session import Session -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from ...utils.pagination.types.meta_response import MetaResponse - - -class PaginatedSessions(UniversalBaseModel): - data: typing.List[Session] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/trace/__init__.py b/langfuse/api/trace/__init__.py index fc9889dfa..37c2eb267 100644 --- a/langfuse/api/trace/__init__.py +++ b/langfuse/api/trace/__init__.py @@ -6,12 +6,8 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import DeleteTraceResponse, Sort, Traces -_dynamic_imports: typing.Dict[str, str] = { - "DeleteTraceResponse": ".types", - "Sort": ".types", - "Traces": ".types", -} + from .types import DeleteTraceResponse +_dynamic_imports: typing.Dict[str, str] = {"DeleteTraceResponse": ".types"} def __getattr__(attr_name: str) -> typing.Any: @@ -41,4 +37,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["DeleteTraceResponse", "Sort", "Traces"] +__all__ = ["DeleteTraceResponse"] diff --git a/langfuse/api/trace/client.py b/langfuse/api/trace/client.py index 2cf76a4a0..406f838c1 100644 --- a/langfuse/api/trace/client.py +++ b/langfuse/api/trace/client.py @@ -1,15 +1,11 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing -import typing_extensions -from ..commons.types.trace_with_full_details import TraceWithFullDetails from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from .raw_client import AsyncRawTraceClient, RawTraceClient from .types.delete_trace_response import DeleteTraceResponse -from .types.traces import Traces # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -30,56 +26,6 @@ def with_raw_response(self) -> RawTraceClient: """ return self._raw_client - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - trace_id: str, - *, - fields: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> TraceWithFullDetails: - """ - Get a specific trace - - Parameters - ---------- - trace_id : str - The unique langfuse identifier of a trace - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - TraceWithFullDetails - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.trace.get( - trace_id="traceId", - ) - """ - _response = self._raw_client.get( - trace_id, fields=fields, request_options=request_options - ) - return _response.data - def delete( self, trace_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> DeleteTraceResponse: @@ -117,219 +63,6 @@ def delete( _response = self._raw_client.delete(trace_id, request_options=request_options) return _response.data - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - order_by: typing.Optional[str] = None, - tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - version: typing.Optional[str] = None, - release: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> Traces: - """ - Get list of traces - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - - name : typing.Optional[str] - - session_id : typing.Optional[str] - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601) - - order_by : typing.Optional[str] - Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc - - tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only traces that include all of these tags will be returned. - - version : typing.Optional[str] - Optional filter to only include traces with a certain version. - - release : typing.Optional[str] - Optional filter to only include traces with a certain release. - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for traces where the environment is one of the provided values. - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, sessionId, tags, version, release, environment, fromTimestamp, toTimestamp). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "booleanObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - booleanObject: "=", "<>" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, booleanObject, and categoryOptions types when filtering on nested fields like metadata or score names - } - ] - ``` - - ## Available Columns - - ### Core Trace Fields - - `id` (string) - Trace ID - - `name` (string) - Trace name - - `timestamp` (datetime) - Trace timestamp - - `userId` (string) - User ID - - `sessionId` (string) - Session ID - - `environment` (string) - Environment tag - - `version` (string) - Version tag - - `release` (string) - Release tag - - `tags` (arrayOptions) - Array of tags - - `bookmarked` (boolean) - Bookmark status - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Aggregated Metrics (from observations) - These metrics are aggregated from all observations within the trace: - - `latency` (number) - Latency in seconds (time from first observation start to last observation end) - - `inputTokens` (number) - Total input tokens across all observations - - `outputTokens` (number) - Total output tokens across all observations - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - `inputCost` (number) - Total input cost in USD - - `outputCost` (number) - Total output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Observation Level Aggregations - These fields aggregate observation levels within the trace: - - `level` (string) - Highest severity level (ERROR > WARNING > DEFAULT > DEBUG) - - `warningCount` (number) - Count of WARNING level observations - - `errorCount` (number) - Count of ERROR level observations - - `defaultCount` (number) - Count of DEFAULT level observations - - `debugCount` (number) - Count of DEBUG level observations - - ### Scores (requires join with scores table) - - `scores_avg` (number) - Average of numeric scores (alias: `scores`) - - `score_categories` (categoryOptions) - Categorical score values - - `score_booleans` (booleanObject) - Boolean score values. Use `key` for the score name and a boolean `value`, e.g. `{"type": "booleanObject", "column": "score_booleans", "key": "is_correct", "operator": "=", "value": true}`. The `<>` operator also matches traces without a score of that name. - - ## Filter Examples - ```json - [ - { - "type": "datetime", - "column": "timestamp", - "operator": ">=", - "value": "2024-01-01T00:00:00Z" - }, - { - "type": "string", - "column": "userId", - "operator": "=", - "value": "user-123" - }, - { - "type": "number", - "column": "totalCost", - "operator": ">=", - "value": 0.01 - }, - { - "type": "arrayOptions", - "column": "tags", - "operator": "all of", - "value": ["production", "critical"] - }, - { - "type": "stringObject", - "column": "metadata", - "key": "customer_tier", - "operator": "=", - "value": "enterprise" - } - ] - ``` - - ## Performance Notes - - Filtering on `userId`, `sessionId`, or `metadata` may enable skip indexes for better query performance - - Score filters require a join with the scores table and may impact query performance - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - Traces - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.trace.list() - """ - _response = self._raw_client.list( - page=page, - limit=limit, - user_id=user_id, - name=name, - session_id=session_id, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - order_by=order_by, - tags=tags, - version=version, - release=release, - environment=environment, - fields=fields, - filter=filter, - request_options=request_options, - ) - return _response.data - def delete_multiple( self, *, @@ -388,64 +121,6 @@ def with_raw_response(self) -> AsyncRawTraceClient: """ return self._raw_client - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - trace_id: str, - *, - fields: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> TraceWithFullDetails: - """ - Get a specific trace - - Parameters - ---------- - trace_id : str - The unique langfuse identifier of a trace - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - TraceWithFullDetails - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.trace.get( - trace_id="traceId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.get( - trace_id, fields=fields, request_options=request_options - ) - return _response.data - async def delete( self, trace_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> DeleteTraceResponse: @@ -493,227 +168,6 @@ async def main() -> None: ) return _response.data - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - order_by: typing.Optional[str] = None, - tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - version: typing.Optional[str] = None, - release: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> Traces: - """ - Get list of traces - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - - name : typing.Optional[str] - - session_id : typing.Optional[str] - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601) - - order_by : typing.Optional[str] - Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc - - tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only traces that include all of these tags will be returned. - - version : typing.Optional[str] - Optional filter to only include traces with a certain version. - - release : typing.Optional[str] - Optional filter to only include traces with a certain release. - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for traces where the environment is one of the provided values. - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, sessionId, tags, version, release, environment, fromTimestamp, toTimestamp). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "booleanObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - booleanObject: "=", "<>" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, booleanObject, and categoryOptions types when filtering on nested fields like metadata or score names - } - ] - ``` - - ## Available Columns - - ### Core Trace Fields - - `id` (string) - Trace ID - - `name` (string) - Trace name - - `timestamp` (datetime) - Trace timestamp - - `userId` (string) - User ID - - `sessionId` (string) - Session ID - - `environment` (string) - Environment tag - - `version` (string) - Version tag - - `release` (string) - Release tag - - `tags` (arrayOptions) - Array of tags - - `bookmarked` (boolean) - Bookmark status - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Aggregated Metrics (from observations) - These metrics are aggregated from all observations within the trace: - - `latency` (number) - Latency in seconds (time from first observation start to last observation end) - - `inputTokens` (number) - Total input tokens across all observations - - `outputTokens` (number) - Total output tokens across all observations - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - `inputCost` (number) - Total input cost in USD - - `outputCost` (number) - Total output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Observation Level Aggregations - These fields aggregate observation levels within the trace: - - `level` (string) - Highest severity level (ERROR > WARNING > DEFAULT > DEBUG) - - `warningCount` (number) - Count of WARNING level observations - - `errorCount` (number) - Count of ERROR level observations - - `defaultCount` (number) - Count of DEFAULT level observations - - `debugCount` (number) - Count of DEBUG level observations - - ### Scores (requires join with scores table) - - `scores_avg` (number) - Average of numeric scores (alias: `scores`) - - `score_categories` (categoryOptions) - Categorical score values - - `score_booleans` (booleanObject) - Boolean score values. Use `key` for the score name and a boolean `value`, e.g. `{"type": "booleanObject", "column": "score_booleans", "key": "is_correct", "operator": "=", "value": true}`. The `<>` operator also matches traces without a score of that name. - - ## Filter Examples - ```json - [ - { - "type": "datetime", - "column": "timestamp", - "operator": ">=", - "value": "2024-01-01T00:00:00Z" - }, - { - "type": "string", - "column": "userId", - "operator": "=", - "value": "user-123" - }, - { - "type": "number", - "column": "totalCost", - "operator": ">=", - "value": 0.01 - }, - { - "type": "arrayOptions", - "column": "tags", - "operator": "all of", - "value": ["production", "critical"] - }, - { - "type": "stringObject", - "column": "metadata", - "key": "customer_tier", - "operator": "=", - "value": "enterprise" - } - ] - ``` - - ## Performance Notes - - Filtering on `userId`, `sessionId`, or `metadata` may enable skip indexes for better query performance - - Score filters require a join with the scores table and may impact query performance - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - Traces - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.trace.list() - - - asyncio.run(main()) - """ - _response = await self._raw_client.list( - page=page, - limit=limit, - user_id=user_id, - name=name, - session_id=session_id, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - order_by=order_by, - tags=tags, - version=version, - release=release, - environment=environment, - fields=fields, - filter=filter, - request_options=request_options, - ) - return _response.data - async def delete_multiple( self, *, diff --git a/langfuse/api/trace/raw_client.py b/langfuse/api/trace/raw_client.py index 7cb22a519..833f4f052 100644 --- a/langfuse/api/trace/raw_client.py +++ b/langfuse/api/trace/raw_client.py @@ -1,8 +1,6 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing -import typing_extensions from json.decoder import JSONDecodeError from ..commons.errors.access_denied_error import AccessDeniedError @@ -10,16 +8,13 @@ from ..commons.errors.method_not_allowed_error import MethodNotAllowedError from ..commons.errors.not_found_error import NotFoundError from ..commons.errors.unauthorized_error import UnauthorizedError -from ..commons.types.trace_with_full_details import TraceWithFullDetails from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import jsonable_encoder from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions from .types.delete_trace_response import DeleteTraceResponse -from .types.traces import Traces # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -29,121 +24,6 @@ class RawTraceClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def get( - self, - trace_id: str, - *, - fields: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[TraceWithFullDetails]: - """ - Get a specific trace - - Parameters - ---------- - trace_id : str - The unique langfuse identifier of a trace - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[TraceWithFullDetails] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/traces/{jsonable_encoder(trace_id)}", - method="GET", - params={ - "fields": fields, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - TraceWithFullDetails, - parse_obj_as( - type_=TraceWithFullDetails, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - def delete( self, trace_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> HttpResponse[DeleteTraceResponse]: @@ -245,289 +125,6 @@ def delete( body=_response_json, ) - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - order_by: typing.Optional[str] = None, - tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - version: typing.Optional[str] = None, - release: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[Traces]: - """ - Get list of traces - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - - name : typing.Optional[str] - - session_id : typing.Optional[str] - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601) - - order_by : typing.Optional[str] - Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc - - tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only traces that include all of these tags will be returned. - - version : typing.Optional[str] - Optional filter to only include traces with a certain version. - - release : typing.Optional[str] - Optional filter to only include traces with a certain release. - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for traces where the environment is one of the provided values. - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, sessionId, tags, version, release, environment, fromTimestamp, toTimestamp). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "booleanObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - booleanObject: "=", "<>" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, booleanObject, and categoryOptions types when filtering on nested fields like metadata or score names - } - ] - ``` - - ## Available Columns - - ### Core Trace Fields - - `id` (string) - Trace ID - - `name` (string) - Trace name - - `timestamp` (datetime) - Trace timestamp - - `userId` (string) - User ID - - `sessionId` (string) - Session ID - - `environment` (string) - Environment tag - - `version` (string) - Version tag - - `release` (string) - Release tag - - `tags` (arrayOptions) - Array of tags - - `bookmarked` (boolean) - Bookmark status - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Aggregated Metrics (from observations) - These metrics are aggregated from all observations within the trace: - - `latency` (number) - Latency in seconds (time from first observation start to last observation end) - - `inputTokens` (number) - Total input tokens across all observations - - `outputTokens` (number) - Total output tokens across all observations - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - `inputCost` (number) - Total input cost in USD - - `outputCost` (number) - Total output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Observation Level Aggregations - These fields aggregate observation levels within the trace: - - `level` (string) - Highest severity level (ERROR > WARNING > DEFAULT > DEBUG) - - `warningCount` (number) - Count of WARNING level observations - - `errorCount` (number) - Count of ERROR level observations - - `defaultCount` (number) - Count of DEFAULT level observations - - `debugCount` (number) - Count of DEBUG level observations - - ### Scores (requires join with scores table) - - `scores_avg` (number) - Average of numeric scores (alias: `scores`) - - `score_categories` (categoryOptions) - Categorical score values - - `score_booleans` (booleanObject) - Boolean score values. Use `key` for the score name and a boolean `value`, e.g. `{"type": "booleanObject", "column": "score_booleans", "key": "is_correct", "operator": "=", "value": true}`. The `<>` operator also matches traces without a score of that name. - - ## Filter Examples - ```json - [ - { - "type": "datetime", - "column": "timestamp", - "operator": ">=", - "value": "2024-01-01T00:00:00Z" - }, - { - "type": "string", - "column": "userId", - "operator": "=", - "value": "user-123" - }, - { - "type": "number", - "column": "totalCost", - "operator": ">=", - "value": 0.01 - }, - { - "type": "arrayOptions", - "column": "tags", - "operator": "all of", - "value": ["production", "critical"] - }, - { - "type": "stringObject", - "column": "metadata", - "key": "customer_tier", - "operator": "=", - "value": "enterprise" - } - ] - ``` - - ## Performance Notes - - Filtering on `userId`, `sessionId`, or `metadata` may enable skip indexes for better query performance - - Score filters require a join with the scores table and may impact query performance - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[Traces] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/traces", - method="GET", - params={ - "page": page, - "limit": limit, - "userId": user_id, - "name": name, - "sessionId": session_id, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "orderBy": order_by, - "tags": tags, - "version": version, - "release": release, - "environment": environment, - "fields": fields, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - Traces, - parse_obj_as( - type_=Traces, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - def delete_multiple( self, *, @@ -641,121 +238,6 @@ class AsyncRawTraceClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def get( - self, - trace_id: str, - *, - fields: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[TraceWithFullDetails]: - """ - Get a specific trace - - Parameters - ---------- - trace_id : str - The unique langfuse identifier of a trace - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[TraceWithFullDetails] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/traces/{jsonable_encoder(trace_id)}", - method="GET", - params={ - "fields": fields, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - TraceWithFullDetails, - parse_obj_as( - type_=TraceWithFullDetails, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - async def delete( self, trace_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[DeleteTraceResponse]: @@ -857,289 +339,6 @@ async def delete( body=_response_json, ) - @typing_extensions.deprecated( - "On Langfuse Cloud, Langfuse v3 is deprecated and this endpoint will be removed on November 16, 2026. In Langfuse v4, read span and trace data via `GET /api/public/v2/observations?fromStartTime=&toStartTime=`. Self-hosted deployments are unaffected by this date; the endpoint becomes unavailable when they upgrade to Langfuse v4.", - category=None, - ) - async def list( - self, - *, - page: typing.Optional[int] = None, - limit: typing.Optional[int] = None, - user_id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - order_by: typing.Optional[str] = None, - tags: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - version: typing.Optional[str] = None, - release: typing.Optional[str] = None, - environment: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, - fields: typing.Optional[str] = None, - filter: typing.Optional[str] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[Traces]: - """ - Get list of traces - - Parameters - ---------- - page : typing.Optional[int] - Page number, starts at 1 - - limit : typing.Optional[int] - Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit. - - user_id : typing.Optional[str] - - name : typing.Optional[str] - - session_id : typing.Optional[str] - - from_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601) - - to_timestamp : typing.Optional[dt.datetime] - Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601) - - order_by : typing.Optional[str] - Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc - - tags : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Only traces that include all of these tags will be returned. - - version : typing.Optional[str] - Optional filter to only include traces with a certain version. - - release : typing.Optional[str] - Optional filter to only include traces with a certain release. - - environment : typing.Optional[typing.Union[str, typing.Sequence[str]]] - Optional filter for traces where the environment is one of the provided values. - - fields : typing.Optional[str] - Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'. - - filter : typing.Optional[str] - JSON string containing an array of filter conditions. When provided, this takes precedence over query parameter filters (userId, name, sessionId, tags, version, release, environment, fromTimestamp, toTimestamp). - - ## Filter Structure - Each filter condition has the following structure: - ```json - [ - { - "type": string, // Required. One of: "datetime", "string", "number", "stringOptions", "categoryOptions", "arrayOptions", "stringObject", "numberObject", "booleanObject", "boolean", "null" - "column": string, // Required. Column to filter on (see available columns below) - "operator": string, // Required. Operator based on type: - // - datetime: ">", "<", ">=", "<=" - // - string: "=", "contains", "does not contain", "starts with", "ends with" - // - stringOptions: "any of", "none of" - // - categoryOptions: "any of", "none of" - // - arrayOptions: "any of", "none of", "all of" - // - number: "=", ">", "<", ">=", "<=" - // - stringObject: "=", "contains", "does not contain", "starts with", "ends with" - // - numberObject: "=", ">", "<", ">=", "<=" - // - booleanObject: "=", "<>" - // - boolean: "=", "<>" - // - null: "is null", "is not null" - "value": any, // Required (except for null type). Value to compare against. Type depends on filter type - "key": string // Required only for stringObject, numberObject, booleanObject, and categoryOptions types when filtering on nested fields like metadata or score names - } - ] - ``` - - ## Available Columns - - ### Core Trace Fields - - `id` (string) - Trace ID - - `name` (string) - Trace name - - `timestamp` (datetime) - Trace timestamp - - `userId` (string) - User ID - - `sessionId` (string) - Session ID - - `environment` (string) - Environment tag - - `version` (string) - Version tag - - `release` (string) - Release tag - - `tags` (arrayOptions) - Array of tags - - `bookmarked` (boolean) - Bookmark status - - ### Structured Data - - `metadata` (stringObject/numberObject/categoryOptions) - Metadata key-value pairs. Use `key` parameter to filter on specific metadata keys. - - ### Aggregated Metrics (from observations) - These metrics are aggregated from all observations within the trace: - - `latency` (number) - Latency in seconds (time from first observation start to last observation end) - - `inputTokens` (number) - Total input tokens across all observations - - `outputTokens` (number) - Total output tokens across all observations - - `totalTokens` (number) - Total tokens (alias: `tokens`) - - `inputCost` (number) - Total input cost in USD - - `outputCost` (number) - Total output cost in USD - - `totalCost` (number) - Total cost in USD - - ### Observation Level Aggregations - These fields aggregate observation levels within the trace: - - `level` (string) - Highest severity level (ERROR > WARNING > DEFAULT > DEBUG) - - `warningCount` (number) - Count of WARNING level observations - - `errorCount` (number) - Count of ERROR level observations - - `defaultCount` (number) - Count of DEFAULT level observations - - `debugCount` (number) - Count of DEBUG level observations - - ### Scores (requires join with scores table) - - `scores_avg` (number) - Average of numeric scores (alias: `scores`) - - `score_categories` (categoryOptions) - Categorical score values - - `score_booleans` (booleanObject) - Boolean score values. Use `key` for the score name and a boolean `value`, e.g. `{"type": "booleanObject", "column": "score_booleans", "key": "is_correct", "operator": "=", "value": true}`. The `<>` operator also matches traces without a score of that name. - - ## Filter Examples - ```json - [ - { - "type": "datetime", - "column": "timestamp", - "operator": ">=", - "value": "2024-01-01T00:00:00Z" - }, - { - "type": "string", - "column": "userId", - "operator": "=", - "value": "user-123" - }, - { - "type": "number", - "column": "totalCost", - "operator": ">=", - "value": 0.01 - }, - { - "type": "arrayOptions", - "column": "tags", - "operator": "all of", - "value": ["production", "critical"] - }, - { - "type": "stringObject", - "column": "metadata", - "key": "customer_tier", - "operator": "=", - "value": "enterprise" - } - ] - ``` - - ## Performance Notes - - Filtering on `userId`, `sessionId`, or `metadata` may enable skip indexes for better query performance - - Score filters require a join with the scores table and may impact query performance - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[Traces] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/traces", - method="GET", - params={ - "page": page, - "limit": limit, - "userId": user_id, - "name": name, - "sessionId": session_id, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - "orderBy": order_by, - "tags": tags, - "version": version, - "release": release, - "environment": environment, - "fields": fields, - "filter": filter, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - Traces, - parse_obj_as( - type_=Traces, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - async def delete_multiple( self, *, diff --git a/langfuse/api/trace/types/__init__.py b/langfuse/api/trace/types/__init__.py index 3eab30d21..fbd53980d 100644 --- a/langfuse/api/trace/types/__init__.py +++ b/langfuse/api/trace/types/__init__.py @@ -7,12 +7,8 @@ if typing.TYPE_CHECKING: from .delete_trace_response import DeleteTraceResponse - from .sort import Sort - from .traces import Traces _dynamic_imports: typing.Dict[str, str] = { - "DeleteTraceResponse": ".delete_trace_response", - "Sort": ".sort", - "Traces": ".traces", + "DeleteTraceResponse": ".delete_trace_response" } @@ -43,4 +39,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["DeleteTraceResponse", "Sort", "Traces"] +__all__ = ["DeleteTraceResponse"] diff --git a/langfuse/api/trace/types/traces.py b/langfuse/api/trace/types/traces.py deleted file mode 100644 index 3ab791cf9..000000000 --- a/langfuse/api/trace/types/traces.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.deprecation import Deprecation -from ...commons.types.trace_with_details import TraceWithDetails -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from ...utils.pagination.types.meta_response import MetaResponse - - -class Traces(UniversalBaseModel): - data: typing.List[TraceWithDetails] - meta: MetaResponse - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/unstable/__init__.py b/langfuse/api/unstable/__init__.py index 2a249200a..331154d29 100644 --- a/langfuse/api/unstable/__init__.py +++ b/langfuse/api/unstable/__init__.py @@ -20,7 +20,7 @@ TooManyRequestsError, UnauthorizedError, ) - from . import dashboard_widgets, dashboards, errors + from . import dashboard_widgets, dashboards, errors, skills from .dashboard_widgets import ( CreateDashboardWidgetRequest, DashboardWidget, @@ -59,6 +59,22 @@ UpdateDashboardRequest, WidgetPlacement, ) + from .skills import ( + CreateSkillVersionRequest, + DeleteSkillVersionResponse, + SkillFile, + SkillFileContent, + SkillFileContentsResponse, + SkillListPagination, + SkillMeta, + SkillMetaListResponse, + SkillVersion, + SkillVersionFileCreateInput, + SkillVersionFileInput, + SkillVersionFileReference, + UpdateSkillLabelsRequest, + UpdateSkillRequest, + ) _dynamic_imports: typing.Dict[str, str] = { "AccessDeniedError": ".errors", "BadRequestError": ".errors", @@ -69,6 +85,7 @@ "CreateDashboardRequest": ".dashboards", "CreateDashboardWidgetRequest": ".dashboard_widgets", "CreatePresetPlacement": ".dashboards", + "CreateSkillVersionRequest": ".skills", "CreateWidgetPlacement": ".dashboards", "Dashboard": ".dashboards", "DashboardDefinition": ".dashboards", @@ -92,6 +109,7 @@ "DeleteDashboardPlacementResponse": ".dashboards", "DeleteDashboardResponse": ".dashboards", "DeleteDashboardWidgetResponse": ".dashboard_widgets", + "DeleteSkillVersionResponse": ".skills", "InternalServerError": ".errors", "MethodNotAllowedError": ".errors", "NotFoundError": ".errors", @@ -100,15 +118,28 @@ "PublicApiErrorCode": ".errors", "PublicApiErrorDetails": ".errors", "PublicApiValidationIssue": ".errors", + "SkillFile": ".skills", + "SkillFileContent": ".skills", + "SkillFileContentsResponse": ".skills", + "SkillListPagination": ".skills", + "SkillMeta": ".skills", + "SkillMetaListResponse": ".skills", + "SkillVersion": ".skills", + "SkillVersionFileCreateInput": ".skills", + "SkillVersionFileInput": ".skills", + "SkillVersionFileReference": ".skills", "TooManyRequestsError": ".errors", "UnauthorizedError": ".errors", "UpdateDashboardPlacementRequest": ".dashboards", "UpdateDashboardRequest": ".dashboards", "UpdateDashboardWidgetRequest": ".dashboard_widgets", + "UpdateSkillLabelsRequest": ".skills", + "UpdateSkillRequest": ".skills", "WidgetPlacement": ".dashboards", "dashboard_widgets": ".dashboard_widgets", "dashboards": ".dashboards", "errors": ".errors", + "skills": ".skills", } @@ -149,6 +180,7 @@ def __dir__(): "CreateDashboardRequest", "CreateDashboardWidgetRequest", "CreatePresetPlacement", + "CreateSkillVersionRequest", "CreateWidgetPlacement", "Dashboard", "DashboardDefinition", @@ -172,6 +204,7 @@ def __dir__(): "DeleteDashboardPlacementResponse", "DeleteDashboardResponse", "DeleteDashboardWidgetResponse", + "DeleteSkillVersionResponse", "InternalServerError", "MethodNotAllowedError", "NotFoundError", @@ -180,13 +213,26 @@ def __dir__(): "PublicApiErrorCode", "PublicApiErrorDetails", "PublicApiValidationIssue", + "SkillFile", + "SkillFileContent", + "SkillFileContentsResponse", + "SkillListPagination", + "SkillMeta", + "SkillMetaListResponse", + "SkillVersion", + "SkillVersionFileCreateInput", + "SkillVersionFileInput", + "SkillVersionFileReference", "TooManyRequestsError", "UnauthorizedError", "UpdateDashboardPlacementRequest", "UpdateDashboardRequest", "UpdateDashboardWidgetRequest", + "UpdateSkillLabelsRequest", + "UpdateSkillRequest", "WidgetPlacement", "dashboard_widgets", "dashboards", "errors", + "skills", ] diff --git a/langfuse/api/unstable/client.py b/langfuse/api/unstable/client.py index 23050bc80..3cc2a9298 100644 --- a/langfuse/api/unstable/client.py +++ b/langfuse/api/unstable/client.py @@ -13,6 +13,7 @@ DashboardWidgetsClient, ) from .dashboards.client import AsyncDashboardsClient, DashboardsClient + from .skills.client import AsyncSkillsClient, SkillsClient class UnstableClient: @@ -21,6 +22,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper self._dashboard_widgets: typing.Optional[DashboardWidgetsClient] = None self._dashboards: typing.Optional[DashboardsClient] = None + self._skills: typing.Optional[SkillsClient] = None @property def with_raw_response(self) -> RawUnstableClient: @@ -51,6 +53,14 @@ def dashboards(self): self._dashboards = DashboardsClient(client_wrapper=self._client_wrapper) return self._dashboards + @property + def skills(self): + if self._skills is None: + from .skills.client import SkillsClient # noqa: E402 + + self._skills = SkillsClient(client_wrapper=self._client_wrapper) + return self._skills + class AsyncUnstableClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -58,6 +68,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper self._dashboard_widgets: typing.Optional[AsyncDashboardWidgetsClient] = None self._dashboards: typing.Optional[AsyncDashboardsClient] = None + self._skills: typing.Optional[AsyncSkillsClient] = None @property def with_raw_response(self) -> AsyncRawUnstableClient: @@ -89,3 +100,11 @@ def dashboards(self): client_wrapper=self._client_wrapper ) return self._dashboards + + @property + def skills(self): + if self._skills is None: + from .skills.client import AsyncSkillsClient # noqa: E402 + + self._skills = AsyncSkillsClient(client_wrapper=self._client_wrapper) + return self._skills diff --git a/langfuse/api/unstable/skills/__init__.py b/langfuse/api/unstable/skills/__init__.py new file mode 100644 index 000000000..710cf53a6 --- /dev/null +++ b/langfuse/api/unstable/skills/__init__.py @@ -0,0 +1,85 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + CreateSkillVersionRequest, + DeleteSkillVersionResponse, + SkillFile, + SkillFileContent, + SkillFileContentsResponse, + SkillListPagination, + SkillMeta, + SkillMetaListResponse, + SkillVersion, + SkillVersionFileCreateInput, + SkillVersionFileInput, + SkillVersionFileReference, + UpdateSkillLabelsRequest, + UpdateSkillRequest, + ) +_dynamic_imports: typing.Dict[str, str] = { + "CreateSkillVersionRequest": ".types", + "DeleteSkillVersionResponse": ".types", + "SkillFile": ".types", + "SkillFileContent": ".types", + "SkillFileContentsResponse": ".types", + "SkillListPagination": ".types", + "SkillMeta": ".types", + "SkillMetaListResponse": ".types", + "SkillVersion": ".types", + "SkillVersionFileCreateInput": ".types", + "SkillVersionFileInput": ".types", + "SkillVersionFileReference": ".types", + "UpdateSkillLabelsRequest": ".types", + "UpdateSkillRequest": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError( + f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" + ) + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError( + f"Failed to import {attr_name} from {module_name}: {e}" + ) from e + except AttributeError as e: + raise AttributeError( + f"Failed to get {attr_name} from {module_name}: {e}" + ) from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateSkillVersionRequest", + "DeleteSkillVersionResponse", + "SkillFile", + "SkillFileContent", + "SkillFileContentsResponse", + "SkillListPagination", + "SkillMeta", + "SkillMetaListResponse", + "SkillVersion", + "SkillVersionFileCreateInput", + "SkillVersionFileInput", + "SkillVersionFileReference", + "UpdateSkillLabelsRequest", + "UpdateSkillRequest", +] diff --git a/langfuse/api/unstable/skills/client.py b/langfuse/api/unstable/skills/client.py new file mode 100644 index 000000000..ffbd4899e --- /dev/null +++ b/langfuse/api/unstable/skills/client.py @@ -0,0 +1,808 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from .raw_client import AsyncRawSkillsClient, RawSkillsClient +from .types.delete_skill_version_response import DeleteSkillVersionResponse +from .types.skill_file_contents_response import SkillFileContentsResponse +from .types.skill_meta_list_response import SkillMetaListResponse +from .types.skill_version import SkillVersion +from .types.skill_version_file_create_input import SkillVersionFileCreateInput + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class SkillsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawSkillsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawSkillsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawSkillsClient + """ + return self._raw_client + + def list( + self, + *, + name: typing.Optional[str] = None, + search: typing.Optional[str] = None, + tag: typing.Optional[str] = None, + page: typing.Optional[int] = None, + limit: typing.Optional[int] = None, + from_updated_at: typing.Optional[dt.datetime] = None, + to_updated_at: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillMetaListResponse: + """ + List skills with metadata and timestamps from their latest version, shared tags, and the version assigned to production when present. + + Parameters + ---------- + name : typing.Optional[str] + Filter by exact skill name. + + search : typing.Optional[str] + Case-insensitive search across skill names and latest-version descriptions. + + tag : typing.Optional[str] + Filter by a shared skill tag. + + page : typing.Optional[int] + + limit : typing.Optional[int] + + from_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated at or after this timestamp. + + to_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated before this timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillMetaListResponse + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.list() + """ + _response = self._raw_client.list( + name=name, + search=search, + tag=tag, + page=page, + limit=limit, + from_updated_at=from_updated_at, + to_updated_at=to_updated_at, + request_options=request_options, + ) + return _response.data + + def create_version( + self, + *, + files: typing.Sequence[SkillVersionFileCreateInput], + commit_message: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Create skill version + + Parameters + ---------- + files : typing.Sequence[SkillVersionFileCreateInput] + + commit_message : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + from langfuse import LangfuseAPI + from langfuse.unstable.skills import SkillVersionFileInput + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.create_version( + files=[ + SkillVersionFileInput( + path="path", + content="content", + ), + SkillVersionFileInput( + path="path", + content="content", + ), + ], + ) + """ + _response = self._raw_client.create_version( + files=files, commit_message=commit_message, request_options=request_options + ) + return _response.data + + def get( + self, + skill_name: str, + *, + version: typing.Optional[int] = None, + label: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Resolve a skill's metadata and file manifest by version or label. Defaults to the production label. Use each file's sha256Hash with getFileContents, individually or in batches across manifests. + + Parameters + ---------- + skill_name : str + + version : typing.Optional[int] + + label : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.get( + skill_name="skillName", + ) + """ + _response = self._raw_client.get( + skill_name, version=version, label=label, request_options=request_options + ) + return _response.data + + def update( + self, + skill_name: str, + *, + tags: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Replace the shared tags across all versions of a skill. An empty tags list clears all tags. Returns the latest skill version with the updated tags. + + Parameters + ---------- + skill_name : str + + tags : typing.Sequence[str] + The complete set of tags to apply to every version of the skill. Pass an empty list to clear all tags. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.update( + skill_name="skillName", + tags=["tags", "tags"], + ) + """ + _response = self._raw_client.update( + skill_name, tags=tags, request_options=request_options + ) + return _response.data + + def get_file_contents( + self, + *, + sha256hashes: str, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillFileContentsResponse: + """ + Read a batch of text contents by canonical base64-encoded SHA-256 hashes. + + Parameters + ---------- + sha256hashes : str + Comma-separated list of one to 50 canonical base64-encoded SHA-256 hashes from skill file manifests. URL-encode the value, including +, /, and = characters. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillFileContentsResponse + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.get_file_contents( + sha256hashes="sha256Hashes", + ) + """ + _response = self._raw_client.get_file_contents( + sha256hashes=sha256hashes, request_options=request_options + ) + return _response.data + + def set_labels( + self, + skill_name: str, + skill_version: int, + *, + labels: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Replace the labels on a skill version and atomically move them from other versions. + + Parameters + ---------- + skill_name : str + + skill_version : int + + labels : typing.Sequence[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.set_labels( + skill_name="skillName", + skill_version=1, + labels=["labels", "labels"], + ) + """ + _response = self._raw_client.set_labels( + skill_name, skill_version, labels=labels, request_options=request_options + ) + return _response.data + + def delete_version( + self, + skill_name: str, + skill_version: int, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeleteSkillVersionResponse: + """ + Delete one immutable skill version. Unreferenced blobs are retained for asynchronous cleanup. + + Parameters + ---------- + skill_name : str + + skill_version : int + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteSkillVersionResponse + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.unstable.skills.delete_version( + skill_name="skillName", + skill_version=1, + ) + """ + _response = self._raw_client.delete_version( + skill_name, skill_version, request_options=request_options + ) + return _response.data + + +class AsyncSkillsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawSkillsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawSkillsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawSkillsClient + """ + return self._raw_client + + async def list( + self, + *, + name: typing.Optional[str] = None, + search: typing.Optional[str] = None, + tag: typing.Optional[str] = None, + page: typing.Optional[int] = None, + limit: typing.Optional[int] = None, + from_updated_at: typing.Optional[dt.datetime] = None, + to_updated_at: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillMetaListResponse: + """ + List skills with metadata and timestamps from their latest version, shared tags, and the version assigned to production when present. + + Parameters + ---------- + name : typing.Optional[str] + Filter by exact skill name. + + search : typing.Optional[str] + Case-insensitive search across skill names and latest-version descriptions. + + tag : typing.Optional[str] + Filter by a shared skill tag. + + page : typing.Optional[int] + + limit : typing.Optional[int] + + from_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated at or after this timestamp. + + to_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated before this timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillMetaListResponse + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + name=name, + search=search, + tag=tag, + page=page, + limit=limit, + from_updated_at=from_updated_at, + to_updated_at=to_updated_at, + request_options=request_options, + ) + return _response.data + + async def create_version( + self, + *, + files: typing.Sequence[SkillVersionFileCreateInput], + commit_message: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Create skill version + + Parameters + ---------- + files : typing.Sequence[SkillVersionFileCreateInput] + + commit_message : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + from langfuse.unstable.skills import SkillVersionFileInput + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.create_version( + files=[ + SkillVersionFileInput( + path="path", + content="content", + ), + SkillVersionFileInput( + path="path", + content="content", + ), + ], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_version( + files=files, commit_message=commit_message, request_options=request_options + ) + return _response.data + + async def get( + self, + skill_name: str, + *, + version: typing.Optional[int] = None, + label: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Resolve a skill's metadata and file manifest by version or label. Defaults to the production label. Use each file's sha256Hash with getFileContents, individually or in batches across manifests. + + Parameters + ---------- + skill_name : str + + version : typing.Optional[int] + + label : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.get( + skill_name="skillName", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get( + skill_name, version=version, label=label, request_options=request_options + ) + return _response.data + + async def update( + self, + skill_name: str, + *, + tags: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Replace the shared tags across all versions of a skill. An empty tags list clears all tags. Returns the latest skill version with the updated tags. + + Parameters + ---------- + skill_name : str + + tags : typing.Sequence[str] + The complete set of tags to apply to every version of the skill. Pass an empty list to clear all tags. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.update( + skill_name="skillName", + tags=["tags", "tags"], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.update( + skill_name, tags=tags, request_options=request_options + ) + return _response.data + + async def get_file_contents( + self, + *, + sha256hashes: str, + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillFileContentsResponse: + """ + Read a batch of text contents by canonical base64-encoded SHA-256 hashes. + + Parameters + ---------- + sha256hashes : str + Comma-separated list of one to 50 canonical base64-encoded SHA-256 hashes from skill file manifests. URL-encode the value, including +, /, and = characters. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillFileContentsResponse + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.get_file_contents( + sha256hashes="sha256Hashes", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_file_contents( + sha256hashes=sha256hashes, request_options=request_options + ) + return _response.data + + async def set_labels( + self, + skill_name: str, + skill_version: int, + *, + labels: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> SkillVersion: + """ + Replace the labels on a skill version and atomically move them from other versions. + + Parameters + ---------- + skill_name : str + + skill_version : int + + labels : typing.Sequence[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SkillVersion + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.set_labels( + skill_name="skillName", + skill_version=1, + labels=["labels", "labels"], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.set_labels( + skill_name, skill_version, labels=labels, request_options=request_options + ) + return _response.data + + async def delete_version( + self, + skill_name: str, + skill_version: int, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeleteSkillVersionResponse: + """ + Delete one immutable skill version. Unreferenced blobs are retained for asynchronous cleanup. + + Parameters + ---------- + skill_name : str + + skill_version : int + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteSkillVersionResponse + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.unstable.skills.delete_version( + skill_name="skillName", + skill_version=1, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete_version( + skill_name, skill_version, request_options=request_options + ) + return _response.data diff --git a/langfuse/api/unstable/skills/raw_client.py b/langfuse/api/unstable/skills/raw_client.py new file mode 100644 index 000000000..152cc2781 --- /dev/null +++ b/langfuse/api/unstable/skills/raw_client.py @@ -0,0 +1,1645 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ...commons.errors.access_denied_error import AccessDeniedError +from ...commons.errors.error import Error +from ...commons.errors.method_not_allowed_error import MethodNotAllowedError +from ...commons.errors.not_found_error import NotFoundError +from ...commons.errors.unauthorized_error import UnauthorizedError +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.datetime_utils import serialize_datetime +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.jsonable_encoder import jsonable_encoder +from ...core.pydantic_utilities import parse_obj_as +from ...core.request_options import RequestOptions +from ...core.serialization import convert_and_respect_annotation_metadata +from .types.delete_skill_version_response import DeleteSkillVersionResponse +from .types.skill_file_contents_response import SkillFileContentsResponse +from .types.skill_meta_list_response import SkillMetaListResponse +from .types.skill_version import SkillVersion +from .types.skill_version_file_create_input import SkillVersionFileCreateInput + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawSkillsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + name: typing.Optional[str] = None, + search: typing.Optional[str] = None, + tag: typing.Optional[str] = None, + page: typing.Optional[int] = None, + limit: typing.Optional[int] = None, + from_updated_at: typing.Optional[dt.datetime] = None, + to_updated_at: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillMetaListResponse]: + """ + List skills with metadata and timestamps from their latest version, shared tags, and the version assigned to production when present. + + Parameters + ---------- + name : typing.Optional[str] + Filter by exact skill name. + + search : typing.Optional[str] + Case-insensitive search across skill names and latest-version descriptions. + + tag : typing.Optional[str] + Filter by a shared skill tag. + + page : typing.Optional[int] + + limit : typing.Optional[int] + + from_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated at or after this timestamp. + + to_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated before this timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillMetaListResponse] + """ + _response = self._client_wrapper.httpx_client.request( + "api/public/unstable/skills", + method="GET", + params={ + "name": name, + "search": search, + "tag": tag, + "page": page, + "limit": limit, + "fromUpdatedAt": serialize_datetime(from_updated_at) + if from_updated_at is not None + else None, + "toUpdatedAt": serialize_datetime(to_updated_at) + if to_updated_at is not None + else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillMetaListResponse, + parse_obj_as( + type_=SkillMetaListResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def create_version( + self, + *, + files: typing.Sequence[SkillVersionFileCreateInput], + commit_message: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillVersion]: + """ + Create skill version + + Parameters + ---------- + files : typing.Sequence[SkillVersionFileCreateInput] + + commit_message : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillVersion] + """ + _response = self._client_wrapper.httpx_client.request( + "api/public/unstable/skills", + method="POST", + json={ + "files": convert_and_respect_annotation_metadata( + object_=files, + annotation=typing.Sequence[SkillVersionFileCreateInput], + direction="write", + ), + "commitMessage": commit_message, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def get( + self, + skill_name: str, + *, + version: typing.Optional[int] = None, + label: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillVersion]: + """ + Resolve a skill's metadata and file manifest by version or label. Defaults to the production label. Use each file's sha256Hash with getFileContents, individually or in batches across manifests. + + Parameters + ---------- + skill_name : str + + version : typing.Optional[int] + + label : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillVersion] + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}", + method="GET", + params={ + "version": version, + "label": label, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def update( + self, + skill_name: str, + *, + tags: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillVersion]: + """ + Replace the shared tags across all versions of a skill. An empty tags list clears all tags. Returns the latest skill version with the updated tags. + + Parameters + ---------- + skill_name : str + + tags : typing.Sequence[str] + The complete set of tags to apply to every version of the skill. Pass an empty list to clear all tags. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillVersion] + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}", + method="PATCH", + json={ + "tags": tags, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def get_file_contents( + self, + *, + sha256hashes: str, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillFileContentsResponse]: + """ + Read a batch of text contents by canonical base64-encoded SHA-256 hashes. + + Parameters + ---------- + sha256hashes : str + Comma-separated list of one to 50 canonical base64-encoded SHA-256 hashes from skill file manifests. URL-encode the value, including +, /, and = characters. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillFileContentsResponse] + """ + _response = self._client_wrapper.httpx_client.request( + "api/public/unstable/skills/files/content", + method="GET", + params={ + "sha256Hashes": sha256hashes, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillFileContentsResponse, + parse_obj_as( + type_=SkillFileContentsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def set_labels( + self, + skill_name: str, + skill_version: int, + *, + labels: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SkillVersion]: + """ + Replace the labels on a skill version and atomically move them from other versions. + + Parameters + ---------- + skill_name : str + + skill_version : int + + labels : typing.Sequence[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SkillVersion] + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}/versions/{jsonable_encoder(skill_version)}", + method="PATCH", + json={ + "labels": labels, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def delete_version( + self, + skill_name: str, + skill_version: int, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[DeleteSkillVersionResponse]: + """ + Delete one immutable skill version. Unreferenced blobs are retained for asynchronous cleanup. + + Parameters + ---------- + skill_name : str + + skill_version : int + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[DeleteSkillVersionResponse] + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}/versions/{jsonable_encoder(skill_version)}", + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteSkillVersionResponse, + parse_obj_as( + type_=DeleteSkillVersionResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + +class AsyncRawSkillsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + name: typing.Optional[str] = None, + search: typing.Optional[str] = None, + tag: typing.Optional[str] = None, + page: typing.Optional[int] = None, + limit: typing.Optional[int] = None, + from_updated_at: typing.Optional[dt.datetime] = None, + to_updated_at: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillMetaListResponse]: + """ + List skills with metadata and timestamps from their latest version, shared tags, and the version assigned to production when present. + + Parameters + ---------- + name : typing.Optional[str] + Filter by exact skill name. + + search : typing.Optional[str] + Case-insensitive search across skill names and latest-version descriptions. + + tag : typing.Optional[str] + Filter by a shared skill tag. + + page : typing.Optional[int] + + limit : typing.Optional[int] + + from_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated at or after this timestamp. + + to_updated_at : typing.Optional[dt.datetime] + Include skills whose latest version was updated before this timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillMetaListResponse] + """ + _response = await self._client_wrapper.httpx_client.request( + "api/public/unstable/skills", + method="GET", + params={ + "name": name, + "search": search, + "tag": tag, + "page": page, + "limit": limit, + "fromUpdatedAt": serialize_datetime(from_updated_at) + if from_updated_at is not None + else None, + "toUpdatedAt": serialize_datetime(to_updated_at) + if to_updated_at is not None + else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillMetaListResponse, + parse_obj_as( + type_=SkillMetaListResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def create_version( + self, + *, + files: typing.Sequence[SkillVersionFileCreateInput], + commit_message: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillVersion]: + """ + Create skill version + + Parameters + ---------- + files : typing.Sequence[SkillVersionFileCreateInput] + + commit_message : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillVersion] + """ + _response = await self._client_wrapper.httpx_client.request( + "api/public/unstable/skills", + method="POST", + json={ + "files": convert_and_respect_annotation_metadata( + object_=files, + annotation=typing.Sequence[SkillVersionFileCreateInput], + direction="write", + ), + "commitMessage": commit_message, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def get( + self, + skill_name: str, + *, + version: typing.Optional[int] = None, + label: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillVersion]: + """ + Resolve a skill's metadata and file manifest by version or label. Defaults to the production label. Use each file's sha256Hash with getFileContents, individually or in batches across manifests. + + Parameters + ---------- + skill_name : str + + version : typing.Optional[int] + + label : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillVersion] + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}", + method="GET", + params={ + "version": version, + "label": label, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def update( + self, + skill_name: str, + *, + tags: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillVersion]: + """ + Replace the shared tags across all versions of a skill. An empty tags list clears all tags. Returns the latest skill version with the updated tags. + + Parameters + ---------- + skill_name : str + + tags : typing.Sequence[str] + The complete set of tags to apply to every version of the skill. Pass an empty list to clear all tags. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillVersion] + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}", + method="PATCH", + json={ + "tags": tags, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def get_file_contents( + self, + *, + sha256hashes: str, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillFileContentsResponse]: + """ + Read a batch of text contents by canonical base64-encoded SHA-256 hashes. + + Parameters + ---------- + sha256hashes : str + Comma-separated list of one to 50 canonical base64-encoded SHA-256 hashes from skill file manifests. URL-encode the value, including +, /, and = characters. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillFileContentsResponse] + """ + _response = await self._client_wrapper.httpx_client.request( + "api/public/unstable/skills/files/content", + method="GET", + params={ + "sha256Hashes": sha256hashes, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillFileContentsResponse, + parse_obj_as( + type_=SkillFileContentsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def set_labels( + self, + skill_name: str, + skill_version: int, + *, + labels: typing.Sequence[str], + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SkillVersion]: + """ + Replace the labels on a skill version and atomically move them from other versions. + + Parameters + ---------- + skill_name : str + + skill_version : int + + labels : typing.Sequence[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SkillVersion] + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}/versions/{jsonable_encoder(skill_version)}", + method="PATCH", + json={ + "labels": labels, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SkillVersion, + parse_obj_as( + type_=SkillVersion, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def delete_version( + self, + skill_name: str, + skill_version: int, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[DeleteSkillVersionResponse]: + """ + Delete one immutable skill version. Unreferenced blobs are retained for asynchronous cleanup. + + Parameters + ---------- + skill_name : str + + skill_version : int + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[DeleteSkillVersionResponse] + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/unstable/skills/{jsonable_encoder(skill_name)}/versions/{jsonable_encoder(skill_version)}", + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteSkillVersionResponse, + parse_obj_as( + type_=DeleteSkillVersionResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) diff --git a/langfuse/api/unstable/skills/types/__init__.py b/langfuse/api/unstable/skills/types/__init__.py new file mode 100644 index 000000000..bafccc040 --- /dev/null +++ b/langfuse/api/unstable/skills/types/__init__.py @@ -0,0 +1,83 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .create_skill_version_request import CreateSkillVersionRequest + from .delete_skill_version_response import DeleteSkillVersionResponse + from .skill_file import SkillFile + from .skill_file_content import SkillFileContent + from .skill_file_contents_response import SkillFileContentsResponse + from .skill_list_pagination import SkillListPagination + from .skill_meta import SkillMeta + from .skill_meta_list_response import SkillMetaListResponse + from .skill_version import SkillVersion + from .skill_version_file_create_input import SkillVersionFileCreateInput + from .skill_version_file_input import SkillVersionFileInput + from .skill_version_file_reference import SkillVersionFileReference + from .update_skill_labels_request import UpdateSkillLabelsRequest + from .update_skill_request import UpdateSkillRequest +_dynamic_imports: typing.Dict[str, str] = { + "CreateSkillVersionRequest": ".create_skill_version_request", + "DeleteSkillVersionResponse": ".delete_skill_version_response", + "SkillFile": ".skill_file", + "SkillFileContent": ".skill_file_content", + "SkillFileContentsResponse": ".skill_file_contents_response", + "SkillListPagination": ".skill_list_pagination", + "SkillMeta": ".skill_meta", + "SkillMetaListResponse": ".skill_meta_list_response", + "SkillVersion": ".skill_version", + "SkillVersionFileCreateInput": ".skill_version_file_create_input", + "SkillVersionFileInput": ".skill_version_file_input", + "SkillVersionFileReference": ".skill_version_file_reference", + "UpdateSkillLabelsRequest": ".update_skill_labels_request", + "UpdateSkillRequest": ".update_skill_request", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError( + f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" + ) + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError( + f"Failed to import {attr_name} from {module_name}: {e}" + ) from e + except AttributeError as e: + raise AttributeError( + f"Failed to get {attr_name} from {module_name}: {e}" + ) from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateSkillVersionRequest", + "DeleteSkillVersionResponse", + "SkillFile", + "SkillFileContent", + "SkillFileContentsResponse", + "SkillListPagination", + "SkillMeta", + "SkillMetaListResponse", + "SkillVersion", + "SkillVersionFileCreateInput", + "SkillVersionFileInput", + "SkillVersionFileReference", + "UpdateSkillLabelsRequest", + "UpdateSkillRequest", +] diff --git a/langfuse/api/unstable/skills/types/create_skill_version_request.py b/langfuse/api/unstable/skills/types/create_skill_version_request.py new file mode 100644 index 000000000..cf7c073ca --- /dev/null +++ b/langfuse/api/unstable/skills/types/create_skill_version_request.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata +from .skill_version_file_create_input import SkillVersionFileCreateInput + + +class CreateSkillVersionRequest(UniversalBaseModel): + files: typing.List[SkillVersionFileCreateInput] + commit_message: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="commitMessage") + ] = None + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/trace/types/sort.py b/langfuse/api/unstable/skills/types/delete_skill_version_response.py similarity index 62% rename from langfuse/api/trace/types/sort.py rename to langfuse/api/unstable/skills/types/delete_skill_version_response.py index b6b992870..cf92c2867 100644 --- a/langfuse/api/trace/types/sort.py +++ b/langfuse/api/unstable/skills/types/delete_skill_version_response.py @@ -3,11 +3,11 @@ import typing import pydantic -from ...core.pydantic_utilities import UniversalBaseModel +from ....core.pydantic_utilities import UniversalBaseModel -class Sort(UniversalBaseModel): - id: str +class DeleteSkillVersionResponse(UniversalBaseModel): + deleted: bool model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/unstable/skills/types/skill_file.py b/langfuse/api/unstable/skills/types/skill_file.py new file mode 100644 index 000000000..683b13679 --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_file.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata + + +class SkillFile(UniversalBaseModel): + id: str + path: str + blob_id: typing_extensions.Annotated[str, FieldMetadata(alias="blobId")] + sha256hash: typing_extensions.Annotated[str, FieldMetadata(alias="sha256Hash")] + content_type: typing_extensions.Annotated[str, FieldMetadata(alias="contentType")] + content_length: typing_extensions.Annotated[ + int, FieldMetadata(alias="contentLength") + ] + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/unstable/skills/types/skill_file_content.py b/langfuse/api/unstable/skills/types/skill_file_content.py new file mode 100644 index 000000000..ce324848d --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_file_content.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata + + +class SkillFileContent(UniversalBaseModel): + sha256hash: typing_extensions.Annotated[str, FieldMetadata(alias="sha256Hash")] + content: str + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/commons/types/numeric_score_v1.py b/langfuse/api/unstable/skills/types/skill_file_contents_response.py similarity index 52% rename from langfuse/api/commons/types/numeric_score_v1.py rename to langfuse/api/unstable/skills/types/skill_file_contents_response.py index 872c89aca..85bb7e2fa 100644 --- a/langfuse/api/commons/types/numeric_score_v1.py +++ b/langfuse/api/unstable/skills/types/skill_file_contents_response.py @@ -3,14 +3,12 @@ import typing import pydantic -from .base_score_v1 import BaseScoreV1 +from ....core.pydantic_utilities import UniversalBaseModel +from .skill_file_content import SkillFileContent -class NumericScoreV1(BaseScoreV1): - value: float = pydantic.Field() - """ - The numeric value of the score - """ +class SkillFileContentsResponse(UniversalBaseModel): + data: typing.List[SkillFileContent] model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/legacy/observations_v1/types/observations.py b/langfuse/api/unstable/skills/types/skill_list_pagination.py similarity index 57% rename from langfuse/api/legacy/observations_v1/types/observations.py rename to langfuse/api/unstable/skills/types/skill_list_pagination.py index 860047ef2..f7ae797de 100644 --- a/langfuse/api/legacy/observations_v1/types/observations.py +++ b/langfuse/api/unstable/skills/types/skill_list_pagination.py @@ -3,14 +3,13 @@ import typing import pydantic -from ....commons.types.observation import Observation -from ....core.pydantic_utilities import UniversalBaseModel +import typing_extensions +from ....core.serialization import FieldMetadata from ....utils.pagination.types.meta_response import MetaResponse -class Observations(UniversalBaseModel): - data: typing.List[Observation] - meta: MetaResponse +class SkillListPagination(MetaResponse): + has_next_page: typing_extensions.Annotated[bool, FieldMetadata(alias="hasNextPage")] model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/unstable/skills/types/skill_meta.py b/langfuse/api/unstable/skills/types/skill_meta.py new file mode 100644 index 000000000..a391972df --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_meta.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata + + +class SkillMeta(UniversalBaseModel): + name: str + description: str + tags: typing.List[str] + latest_version: typing_extensions.Annotated[ + int, FieldMetadata(alias="latestVersion") + ] + latest_version_created_at: typing_extensions.Annotated[ + dt.datetime, FieldMetadata(alias="latestVersionCreatedAt") + ] + latest_version_last_updated_at: typing_extensions.Annotated[ + dt.datetime, FieldMetadata(alias="latestVersionLastUpdatedAt") + ] + production_version: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="productionVersion") + ] = pydantic.Field(default=None) + """ + Version assigned the production label, or null when no version is assigned. + """ + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/unstable/skills/types/skill_meta_list_response.py b/langfuse/api/unstable/skills/types/skill_meta_list_response.py new file mode 100644 index 000000000..a097e1bc0 --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_meta_list_response.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import UniversalBaseModel +from .skill_list_pagination import SkillListPagination +from .skill_meta import SkillMeta + + +class SkillMetaListResponse(UniversalBaseModel): + data: typing.List[SkillMeta] + meta: SkillListPagination + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/unstable/skills/types/skill_version.py b/langfuse/api/unstable/skills/types/skill_version.py new file mode 100644 index 000000000..c3486c23d --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_version.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata +from .skill_file import SkillFile + + +class SkillVersion(UniversalBaseModel): + id: str + created_at: typing_extensions.Annotated[ + dt.datetime, FieldMetadata(alias="createdAt") + ] + updated_at: typing_extensions.Annotated[ + dt.datetime, FieldMetadata(alias="updatedAt") + ] + project_id: typing_extensions.Annotated[str, FieldMetadata(alias="projectId")] + created_by: typing_extensions.Annotated[str, FieldMetadata(alias="createdBy")] + name: str + description: str + frontmatter: typing.Any + version: int + tags: typing.List[str] + labels: typing.List[str] + commit_message: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="commitMessage") + ] = None + files: typing.List[SkillFile] + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/unstable/skills/types/skill_version_file_create_input.py b/langfuse/api/unstable/skills/types/skill_version_file_create_input.py new file mode 100644 index 000000000..495002d8c --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_version_file_create_input.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .skill_version_file_input import SkillVersionFileInput +from .skill_version_file_reference import SkillVersionFileReference + +SkillVersionFileCreateInput = typing.Union[ + SkillVersionFileInput, SkillVersionFileReference +] diff --git a/langfuse/api/unstable/skills/types/skill_version_file_input.py b/langfuse/api/unstable/skills/types/skill_version_file_input.py new file mode 100644 index 000000000..c6f20d51e --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_version_file_input.py @@ -0,0 +1,18 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import UniversalBaseModel + + +class SkillVersionFileInput(UniversalBaseModel): + path: str + content: str = pydantic.Field() + """ + UTF-8 text content. The server computes the hash and byte length. + """ + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/unstable/skills/types/skill_version_file_reference.py b/langfuse/api/unstable/skills/types/skill_version_file_reference.py new file mode 100644 index 000000000..4960d9e90 --- /dev/null +++ b/langfuse/api/unstable/skills/types/skill_version_file_reference.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import UniversalBaseModel +from ....core.serialization import FieldMetadata + + +class SkillVersionFileReference(UniversalBaseModel): + path: str + sha256hash: typing_extensions.Annotated[str, FieldMetadata(alias="sha256Hash")] = ( + pydantic.Field() + ) + """ + Canonical base64-encoded SHA-256 from a skill file manifest. The content must already exist in this project. + """ + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/ingestion/types/sdk_log_event.py b/langfuse/api/unstable/skills/types/update_skill_labels_request.py similarity index 60% rename from langfuse/api/ingestion/types/sdk_log_event.py rename to langfuse/api/unstable/skills/types/update_skill_labels_request.py index ca303af55..c1931a1e4 100644 --- a/langfuse/api/ingestion/types/sdk_log_event.py +++ b/langfuse/api/unstable/skills/types/update_skill_labels_request.py @@ -3,12 +3,11 @@ import typing import pydantic -from .base_event import BaseEvent -from .sdk_log_body import SdkLogBody +from ....core.pydantic_utilities import UniversalBaseModel -class SdkLogEvent(BaseEvent): - body: SdkLogBody +class UpdateSkillLabelsRequest(UniversalBaseModel): + labels: typing.List[str] model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True diff --git a/langfuse/api/unstable/skills/types/update_skill_request.py b/langfuse/api/unstable/skills/types/update_skill_request.py new file mode 100644 index 000000000..3c211b921 --- /dev/null +++ b/langfuse/api/unstable/skills/types/update_skill_request.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import UniversalBaseModel + + +class UpdateSkillRequest(UniversalBaseModel): + tags: typing.List[str] = pydantic.Field() + """ + The complete set of tags to apply to every version of the skill. Pass an empty list to clear all tags. + """ + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/langfuse/api/utils/__init__.py b/langfuse/api/utils/__init__.py index b272f64b5..1690ee475 100644 --- a/langfuse/api/utils/__init__.py +++ b/langfuse/api/utils/__init__.py @@ -7,8 +7,9 @@ if typing.TYPE_CHECKING: from . import pagination - from .pagination import MetaResponse + from .pagination import CursorMetaResponse, MetaResponse _dynamic_imports: typing.Dict[str, str] = { + "CursorMetaResponse": ".pagination", "MetaResponse": ".pagination", "pagination": ".pagination", } @@ -41,4 +42,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["MetaResponse", "pagination"] +__all__ = ["CursorMetaResponse", "MetaResponse", "pagination"] diff --git a/langfuse/api/utils/pagination/__init__.py b/langfuse/api/utils/pagination/__init__.py index 50821832d..59cdf0321 100644 --- a/langfuse/api/utils/pagination/__init__.py +++ b/langfuse/api/utils/pagination/__init__.py @@ -6,8 +6,11 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import MetaResponse -_dynamic_imports: typing.Dict[str, str] = {"MetaResponse": ".types"} + from .types import CursorMetaResponse, MetaResponse +_dynamic_imports: typing.Dict[str, str] = { + "CursorMetaResponse": ".types", + "MetaResponse": ".types", +} def __getattr__(attr_name: str) -> typing.Any: @@ -37,4 +40,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["MetaResponse"] +__all__ = ["CursorMetaResponse", "MetaResponse"] diff --git a/langfuse/api/utils/pagination/types/__init__.py b/langfuse/api/utils/pagination/types/__init__.py index 5c0d83028..85d13aee0 100644 --- a/langfuse/api/utils/pagination/types/__init__.py +++ b/langfuse/api/utils/pagination/types/__init__.py @@ -6,8 +6,12 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .cursor_meta_response import CursorMetaResponse from .meta_response import MetaResponse -_dynamic_imports: typing.Dict[str, str] = {"MetaResponse": ".meta_response"} +_dynamic_imports: typing.Dict[str, str] = { + "CursorMetaResponse": ".cursor_meta_response", + "MetaResponse": ".meta_response", +} def __getattr__(attr_name: str) -> typing.Any: @@ -37,4 +41,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["MetaResponse"] +__all__ = ["CursorMetaResponse", "MetaResponse"] diff --git a/langfuse/api/utils/pagination/types/cursor_meta_response.py b/langfuse/api/utils/pagination/types/cursor_meta_response.py new file mode 100644 index 000000000..eb30740ac --- /dev/null +++ b/langfuse/api/utils/pagination/types/cursor_meta_response.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import UniversalBaseModel + + +class CursorMetaResponse(UniversalBaseModel): + """ + Metadata for cursor-based pagination. + """ + + cursor: typing.Optional[str] = pydantic.Field(default=None) + """ + Opaque cursor for the next page. Omitted when there is no next page. + """ + + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) diff --git a/scripts/patch_generated_score_compat.py b/scripts/patch_generated_score_compat.py index 46c23ec6b..b94784745 100644 --- a/scripts/patch_generated_score_compat.py +++ b/scripts/patch_generated_score_compat.py @@ -11,6 +11,7 @@ import argparse import ast +import re from pathlib import Path CLIENT_IMPORTS = """\ @@ -21,10 +22,28 @@ ScoresClient as CanonicalScoresClient, OMIT, ) +from ...scores.types.create_score_request import CreateScoreRequest from ...scores.types.create_score_response import CreateScoreResponse from ...scores.types.create_score_source import CreateScoreSource """ +# Earlier alias generations, stripped before the current alias is re-applied. +PREVIOUS_CLIENT_IMPORTS = ( + """\ +from ...commons.types.create_score_value import CreateScoreValue +from ...commons.types.score_data_type import ScoreDataType +from ...scores.client import ( + AsyncScoresClient as CanonicalAsyncScoresClient, + ScoresClient as CanonicalScoresClient, + OMIT, +) +from ...scores.types.create_score_response import CreateScoreResponse +from ...scores.types.create_score_source import CreateScoreSource +""", +) + +# A single-score request always returns CreateScoreResponse; the batch members +# of the CreateScoresResponse union only answer list requests. SYNC_CREATE_METHOD = '''\ def create( self, @@ -46,25 +65,33 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoreResponse: """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - return CanonicalScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create( + optional_fields: typing.Dict[str, typing.Any] = { + "id": id, + "trace_id": trace_id, + "session_id": session_id, + "observation_id": observation_id, + "dataset_run_id": dataset_run_id, + "comment": comment, + "metadata": metadata, + "environment": environment, + "queue_id": queue_id, + "data_type": data_type, + "config_id": config_id, + "source": source, + } + request = CreateScoreRequest( name=name, value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, + **{ + key: field + for key, field in optional_fields.items() + if field is not OMIT + }, ) + response = CanonicalScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create(request=request, request_options=request_options) + return typing.cast(CreateScoreResponse, response) ''' @@ -89,25 +116,33 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoreResponse: """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - return await CanonicalAsyncScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create( + optional_fields: typing.Dict[str, typing.Any] = { + "id": id, + "trace_id": trace_id, + "session_id": session_id, + "observation_id": observation_id, + "dataset_run_id": dataset_run_id, + "comment": comment, + "metadata": metadata, + "environment": environment, + "queue_id": queue_id, + "data_type": data_type, + "config_id": config_id, + "source": source, + } + request = CreateScoreRequest( name=name, value=value, - id=id, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - dataset_run_id=dataset_run_id, - comment=comment, - metadata=metadata, - environment=environment, - queue_id=queue_id, - data_type=data_type, - config_id=config_id, - source=source, - request_options=request_options, + **{ + key: field + for key, field in optional_fields.items() + if field is not OMIT + }, ) + response = await CanonicalAsyncScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create(request=request, request_options=request_options) + return typing.cast(CreateScoreResponse, response) ''' @@ -178,6 +213,26 @@ def _create_type_aliases(types_dir: Path) -> None: ) +def _strip_compat_alias(contents: str, *, path: Path) -> str: + """Remove a previously applied alias so the current template can be re-applied.""" + for imports in (CLIENT_IMPORTS, *PREVIOUS_CLIENT_IMPORTS): + if imports in contents: + contents = contents.replace(imports, "", 1) + break + else: + raise RuntimeError(f"Unrecognized compatibility imports in {path}") + + stripped = re.sub( + r"\n (async )?def create\(.*?(?=\n (async )?def delete\()", + "", + contents, + flags=re.DOTALL, + ) + if LEGACY_ALIAS_MARKER in stripped: + raise RuntimeError(f"Could not remove the compatibility alias from {path}") + return stripped + + def patch_generated_api(api_root: Path) -> bool: canonical_client_path = api_root / "scores" / "client.py" legacy_client_path = api_root / "legacy" / "score_v1" / "client.py" @@ -196,7 +251,11 @@ def patch_generated_api(api_root: Path) -> bool: if legacy_has_create: if LEGACY_ALIAS_MARKER in legacy_client and canonical_has_create: - return False + if SYNC_CREATE_METHOD in legacy_client and CLIENT_IMPORTS in legacy_client: + return False + legacy_client = _strip_compat_alias(legacy_client, path=legacy_client_path) + legacy_client_path.write_text(legacy_client) + return patch_generated_api(api_root) if canonical_has_create: raise RuntimeError( "Both canonical and legacy generated score clients define create; " diff --git a/tests/unit/test_patch_generated_score_compat.py b/tests/unit/test_patch_generated_score_compat.py index 384abc4be..fd0bea9bf 100644 --- a/tests/unit/test_patch_generated_score_compat.py +++ b/tests/unit/test_patch_generated_score_compat.py @@ -106,6 +106,10 @@ def test_generated_sync_alias_delegates_to_canonical_client() -> None: calls: dict[str, object] = {} omitted = object() + class FakeCreateScoreRequest: + def __init__(self, **fields: object): + self.fields = fields + class FakeCanonicalScoresClient: def __init__(self, *, client_wrapper: object): calls["client_wrapper"] = client_wrapper @@ -116,6 +120,7 @@ def create(self, **kwargs: object) -> str: namespace = { "CanonicalScoresClient": FakeCanonicalScoresClient, + "CreateScoreRequest": FakeCreateScoreRequest, "CreateScoreResponse": str, "CreateScoreSource": object, "CreateScoreValue": object, @@ -140,20 +145,46 @@ def create(self, **kwargs: object) -> str: assert response == "created" assert calls["client_wrapper"] is client_wrapper - assert calls["kwargs"] == { + kwargs = typing.cast(dict, calls["kwargs"]) + assert set(kwargs) == {"request", "request_options"} + assert kwargs["request_options"] is None + # Arguments that were not passed are left out of the request body. + assert kwargs["request"].fields == { "name": "quality", "value": 0.9, - "id": omitted, "trace_id": "trace-id", - "session_id": omitted, - "observation_id": omitted, - "dataset_run_id": omitted, - "comment": omitted, - "metadata": omitted, - "environment": omitted, - "queue_id": omitted, - "data_type": omitted, - "config_id": omitted, - "source": omitted, - "request_options": None, } + + +def test_postprocessor_upgrades_a_previously_applied_alias(tmp_path: Path) -> None: + postprocessor = run_path("scripts/patch_generated_score_compat.py") + api_root = tmp_path / "api" + _write_generated_fixture(api_root, canonical_has_create=True) + assert _run_postprocessor(api_root).returncode == 0 + + client_path = api_root / "legacy" / "score_v1" / "client.py" + client = client_path.read_text() + previous_imports = postprocessor["PREVIOUS_CLIENT_IMPORTS"][0] + old_method = ( + " def create(self, *, name, value):\n" + ' """**Deprecated compatibility alias.** Use ``client.scores.create``."""\n' + " return CanonicalScoresClient().create(name=name, value=value)\n\n" + ) + old_async_method = old_method.replace("def create", "async def create") + client = client.replace(postprocessor["CLIENT_IMPORTS"], previous_imports) + client = client.replace(postprocessor["SYNC_CREATE_METHOD"], old_method) + client = client.replace(postprocessor["ASYNC_CREATE_METHOD"], old_async_method) + client_path.write_text(client) + + result = _run_postprocessor(api_root) + + assert result.returncode == 0, result.stderr + upgraded = client_path.read_text() + assert upgraded.count("def create(") == 2 + assert "create(request=request" in upgraded + assert "create(name=name, value=value)" not in upgraded + assert "from ...scores.types.create_score_request import CreateScoreRequest" in ( + upgraded + ) + compile(upgraded, "", "exec") + assert _run_postprocessor(api_root).stdout.strip() == "No patch needed." From 18413db0fe8949057ff861fd319aa778bb5d8834 Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Fri, 9 Oct 2026 10:17:27 +0200 Subject: [PATCH 14/31] feat(api): update API spec from langfuse/langfuse 5beee5d (#1962) Co-authored-by: langfuse-bot --- langfuse/api/scores/types/create_scores_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/langfuse/api/scores/types/create_scores_response.py b/langfuse/api/scores/types/create_scores_response.py index 8cf007796..c3f4f7f10 100644 --- a/langfuse/api/scores/types/create_scores_response.py +++ b/langfuse/api/scores/types/create_scores_response.py @@ -7,5 +7,5 @@ from .create_score_response import CreateScoreResponse CreateScoresResponse = typing.Union[ - CreateScoreResponse, CreateScoreBatchResponse, CreateScoreBatchResults + CreateScoreBatchResults, CreateScoreResponse, CreateScoreBatchResponse ] From f91a6aa4fc5288dc24cd54c4cd30556008c181ed Mon Sep 17 00:00:00 2001 From: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:47:09 +0200 Subject: [PATCH 15/31] fix(tracing): keep metadata within the span attribute limit (#1957) * fix(tracing): keep metadata within the span attribute limit Each metadata key is written as its own OTel attribute. Once a span holds more than SpanLimits.max_span_attributes (default 128) attributes, the OTel SDK evicts the oldest ones, which are the Langfuse core attributes such as input, model and version. Before writing observation attributes, count the span's existing attribute keys plus the new keys and drop the excess new metadata keys from the tail. Non-metadata attributes and overwrites of existing keys are never dropped. Log one warning per write that dropped keys instead of raising. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tracing): reserve attribute slots for later observation updates Capping metadata at exactly the span attribute limit left no room for attributes written in later updates, so e.g. update(output=...) made the OTel SDK evict the oldest attribute (langfuse.internal.is_app_root, then input, model, ...). Metadata now also leaves one slot for each reserved observation attribute that is not on the span or in the current write yet, matching langfuse-js. Co-Authored-By: Claude Opus 5.5 (1M context) * test(tracing): pin span attribute limit in metadata limit tests The tests assert the OTel default of 128 attributes, so a custom OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT or OTEL_ATTRIBUTE_COUNT_LIMIT in the environment made them fail. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tracing): drop newest attributes instead of evicting on span attribute overflow The Python OTel SDK evicts the oldest attribute once a span is full, while OTel JS drops the newest. After capping metadata, the limit helper now also drops excess new non-metadata keys from the tail when the span has no physical capacity left, so Langfuse writes never evict existing attributes. Overwrites of existing keys are always kept, and at most one warning is logged per write. Langfuse write paths on observation spans now go through the guard: the observation constructor, update(), media-processed updates, set_trace_as_public, the experiment item attribute writes, as_root marking, propagated attributes in the span processor and propagate_attributes, and the app-root marker. The warning now names SpanLimits.max_span_attributes alongside OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, matching langfuse-js. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(experiments): keep experiment run keys when metadata hits the span limit The task span metadata put experiment_name, experiment_run_name and the dataset ids after item and experiment metadata, so the span attribute limit dropped them first. Insert the run keys first so they survive truncation, and again last so they still win over user keys of the same name. Mirrors langfuse-js. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tracing): reserve attribute slots by observation type Span-like observations and events never write model, usage, cost, completion start time, model parameters or prompt attributes, so reserving slots for them wasted 7 metadata keys. Reserve the 7 shared keys (type, level, status message, version, environment, input, output) for span-like types and events, and all 14 for generation-like types and for writes whose observation type is unknown (as_root, propagated attributes, app-root marker). Mirrors langfuse-js. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(experiments): propagate experiment metadata as one JSON attribute Experiment metadata and experiment item metadata were propagated as one attribute per flattened key (langfuse.experiment.item.metadata.), so large item metadata filled the task span and its children and left no room for later attributes such as the output. Send each as a single serialized JSON attribute (langfuse.experiment.metadata and langfuse.experiment.item.metadata), like langfuse-js. The server parses the blob at the bare prefix and flattens it to the same paths. Experiment attributes are set by the SDK and skip the 200-character validation of user-propagated values, matching langfuse-js. Remove the now unused _flatten_and_serialize_metadata_values. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(experiments): write task span metadata after the experiment attributes The experiment task span got its observation metadata at start, before the experiment attributes were copied onto it. The metadata cap could then fill the span up to the reserved slots, and the copied attributes used those slots, so the later output was dropped. Start the task span without metadata and set it inside the propagation block, before the task runs, so the cap counts the copied attributes and trims the observation metadata instead. The experiment run keys still go first. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- langfuse/_client/attributes.py | 26 - langfuse/_client/client.py | 63 +- langfuse/_client/propagation.py | 42 +- langfuse/_client/span.py | 228 +++++++- langfuse/_client/span_processor.py | 7 +- tests/unit/test_experiment.py | 17 +- tests/unit/test_metadata_attribute_limit.py | 602 ++++++++++++++++++++ tests/unit/test_propagate_attributes.py | 114 ++-- 8 files changed, 944 insertions(+), 155 deletions(-) create mode 100644 tests/unit/test_metadata_attribute_limit.py diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index e7027a192..c744dfa82 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -160,32 +160,6 @@ def _serialize(obj: Any) -> Optional[str]: return json.dumps(obj, cls=EventSerializer) -def _flatten_and_serialize_metadata_values( - metadata: Optional[Dict[str, Any]], -) -> Optional[Dict[str, str]]: - if metadata is None: - return None - - flattened_metadata: Dict[str, str] = {} - - def flatten_value(path: str, value: Any) -> None: - if isinstance(value, dict): - for nested_key, nested_value in value.items(): - flatten_value(f"{path}.{nested_key}", nested_value) - - return - - serialized_value = _serialize(value) - - if serialized_value is not None: - flattened_metadata[path] = serialized_value - - for key, value in metadata.items(): - flatten_value(str(key), value) - - return flattened_metadata - - def _flatten_and_serialize_metadata( metadata: Any, type: Literal["observation", "trace"] ) -> dict: diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 5889bd35c..d8349f802 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -41,7 +41,6 @@ from langfuse._client.attributes import ( LangfuseOtelSpanAttributes, - _flatten_and_serialize_metadata_values, _serialize, ) from langfuse._client.constants import ( @@ -83,6 +82,7 @@ LangfuseRetriever, LangfuseSpan, LangfuseTool, + _set_span_attributes_within_limit, ) from langfuse._client.utils import ( get_sha256_hash_hex, @@ -688,7 +688,9 @@ def start_observation( cast(otel_trace_api.Span, remote_parent_span) ): otel_span = self._otel_tracer.start_span(name=name) - otel_span.set_attribute(LangfuseOtelSpanAttributes.AS_ROOT, True) + _set_span_attributes_within_limit( + otel_span, {LangfuseOtelSpanAttributes.AS_ROOT: True} + ) return self._create_observation_from_otel_span( otel_span=otel_span, @@ -1278,8 +1280,9 @@ def _create_span_with_parent_context( prompt=prompt, ) as langfuse_span: if remote_parent_span is not None: - langfuse_span._otel_span.set_attribute( - LangfuseOtelSpanAttributes.AS_ROOT, True + _set_span_attributes_within_limit( + langfuse_span._otel_span, + {LangfuseOtelSpanAttributes.AS_ROOT: True}, ) yield langfuse_span @@ -1609,7 +1612,9 @@ def create_event( otel_span = self._otel_tracer.start_span( name=name, start_time=timestamp ) - otel_span.set_attribute(LangfuseOtelSpanAttributes.AS_ROOT, True) + _set_span_attributes_within_limit( + otel_span, {LangfuseOtelSpanAttributes.AS_ROOT: True} + ) return cast( LangfuseEvent, @@ -2879,17 +2884,15 @@ async def _process_experiment_item( else getattr(item, "metadata", None) ) - final_observation_metadata = { - **(item_metadata if isinstance(item_metadata, dict) else {}), - **(experiment_metadata or {}), - "experiment_name": experiment_name, - "experiment_run_name": experiment_run_name, - } - trace_id = span.trace_id dataset_id = None dataset_item_id = None + experiment_run_metadata: Dict[str, Any] = { + "experiment_name": experiment_name, + "experiment_run_name": experiment_run_name, + } + if ( not isinstance(item, dict) and hasattr(item, "dataset_id") @@ -2898,10 +2901,20 @@ async def _process_experiment_item( dataset_id = item.dataset_id dataset_item_id = item.id - final_observation_metadata.update( + experiment_run_metadata.update( {"dataset_id": dataset_id, "dataset_item_id": dataset_item_id} ) + # Experiment run keys go first so the span attribute limit drops + # user metadata before them, and last so they still win over + # user keys. + final_observation_metadata = { + **experiment_run_metadata, + **(item_metadata if isinstance(item_metadata, dict) else {}), + **(experiment_metadata or {}), + **experiment_run_metadata, + } + experiment_item_id = ( dataset_item_id or get_sha256_hash_hex(_serialize(input_data))[:16] ) @@ -2919,25 +2932,26 @@ async def _process_experiment_item( }.items() if v is not None } - span._otel_span.set_attributes(experiment_span_attributes) + _set_span_attributes_within_limit( + span._otel_span, experiment_span_attributes + ) with span.start_as_current_observation( name="experiment-item-task", as_type="span", input=input_data, - metadata=final_observation_metadata, ) as task_span: - task_span._otel_span.set_attributes(experiment_span_attributes) + _set_span_attributes_within_limit( + task_span._otel_span, experiment_span_attributes + ) propagated_experiment_attributes = PropagatedExperimentAttributes( experiment_id=experiment_id, experiment_name=experiment_run_name, - experiment_metadata=_flatten_and_serialize_metadata_values( - experiment_metadata - ), + experiment_metadata=_serialize(experiment_metadata), experiment_dataset_id=dataset_id, experiment_item_id=experiment_item_id, - experiment_item_metadata=_flatten_and_serialize_metadata_values( + experiment_item_metadata=_serialize( item_metadata if isinstance(item_metadata, dict) else None ), experiment_item_root_observation_id=task_span.id, @@ -2948,11 +2962,16 @@ async def _process_experiment_item( ): # _propagate_attributes updates the current task span and future children. # Explicitly backfill the parent item-run span to preserve experiment association. - span._otel_span.set_attributes( + _set_span_attributes_within_limit( + span._otel_span, _get_propagated_attributes_from_context( otel_context_api.get_current() - ) + ), ) + # Write the observation metadata after the experiment + # attributes, so the span attribute limit trims the + # metadata instead of leaving no room for the output. + task_span.update(metadata=final_observation_metadata) try: output = await _run_task(task, item) except Exception as e: diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 20aefe7f1..a79f57260 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -41,6 +41,7 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT +from langfuse._client.span import _set_span_attributes_within_limit from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger from langfuse.model import PromptClient @@ -90,10 +91,10 @@ class PropagatedExperimentAttributes(TypedDict): experiment_id: str experiment_name: str - experiment_metadata: Optional[Dict[str, str]] + experiment_metadata: Optional[str] # serialized JSON experiment_dataset_id: Optional[str] experiment_item_id: str - experiment_item_metadata: Optional[Dict[str, str]] + experiment_item_metadata: Optional[str] # serialized JSON experiment_item_root_observation_id: str @@ -363,17 +364,6 @@ def _propagate_attributes( "metadata": metadata, } - if experiment: - for key, value in experiment.items(): - if key in ("experiment_metadata", "experiment_item_metadata"): - propagated_metadata_attributes[key] = cast( - Optional[Dict[str, str]], value - ) - else: - propagated_string_attributes[key] = cast( - Optional[Union[str, List[str]]], value - ) - # Filter out None values propagated_string_attributes = { k: v for k, v in propagated_string_attributes.items() if v is not None @@ -423,6 +413,19 @@ def _propagate_attributes( as_baggage=as_baggage, ) + # Experiment attributes are set by the SDK and already serialized, so they + # skip validation. Mirrors langfuse-js. + if experiment: + for experiment_key, experiment_value in experiment.items(): + if experiment_value is not None: + context = _set_propagated_attribute( + key=experiment_key, + value=cast(str, experiment_value), + context=context, + span=current_span, + as_baggage=as_baggage, + ) + # Activate context, execute, and detach context token = otel_context_api.attach(context=context) @@ -599,14 +602,13 @@ def _set_propagated_attribute( if span is not None and span.is_recording(): if isinstance(value, dict): # Handle metadata - for k, v in value.items(): - span.set_attribute( - key=f"{span_key}.{k}", - value=v, - ) - + span_attributes: Dict[str, Any] = { + f"{span_key}.{k}": v for k, v in value.items() + } else: - span.set_attribute(key=span_key, value=value) + span_attributes = {span_key: value} + + _set_span_attributes_within_limit(span, span_attributes) # Set on baggage if as_baggage: diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 9e2b5b718..0c1d6db90 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -19,8 +19,10 @@ TYPE_CHECKING, Any, Dict, + List, Literal, Optional, + Tuple, Type, Union, cast, @@ -61,6 +63,189 @@ _OBSERVATION_CLASS_MAP: Dict[str, Type["LangfuseObservationWrapper"]] = {} +def _is_observation_metadata_key(key: str) -> bool: + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + return key == prefix or key.startswith(prefix + ".") + + +# Attributes the SDK may write in later updates of an observation. Metadata +# leaves room for them, so that a later update (e.g. the output) still fits +# into the span. Span-like observations and events only use the shared keys; +# generation-like observations, and writes whose observation type is unknown, +# also reserve the model keys. Mirrors langfuse-js. +_RESERVED_SPAN_ATTRIBUTE_KEYS: Tuple[str, ...] = ( + LangfuseOtelSpanAttributes.OBSERVATION_TYPE, + LangfuseOtelSpanAttributes.OBSERVATION_LEVEL, + LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE, + LangfuseOtelSpanAttributes.VERSION, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LangfuseOtelSpanAttributes.OBSERVATION_INPUT, + LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, +) +_RESERVED_OBSERVATION_ATTRIBUTE_KEYS: Tuple[str, ...] = ( + *_RESERVED_SPAN_ATTRIBUTE_KEYS, + LangfuseOtelSpanAttributes.OBSERVATION_MODEL, + LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS, + LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS, + LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME, + LangfuseOtelSpanAttributes.OBSERVATION_MODEL_PARAMETERS, + LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME, + LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION, +) + + +def _reserved_attribute_keys(observation_type: Optional[str]) -> Tuple[str, ...]: + if ( + observation_type in get_observation_types_list(ObservationTypeSpanLike) + or observation_type == "event" + ): + return _RESERVED_SPAN_ATTRIBUTE_KEYS + + return _RESERVED_OBSERVATION_ATTRIBUTE_KEYS + + +def _drop_attributes_over_span_limit( + span: otel_trace_api.Span, + attributes: Dict[str, Any], + reserved_keys: Tuple[str, ...] = _RESERVED_OBSERVATION_ATTRIBUTE_KEYS, +) -> Dict[str, Any]: + """Drop new attributes that would exceed the span's attribute limit. + + Once a span is full, the Python OTel SDK evicts the oldest attribute, which + would drop Langfuse core attributes (input, model, ...) written earlier. + OTel JS drops the newest attribute instead; this helper gives Python the + same semantics so existing attributes are never evicted. + + 1. Metadata budget: counts the attributes already on the span, the new + keys, and the reserved keys not yet on the span, which later updates + may still write. Excess new metadata keys are dropped from the tail. + ``reserved_keys`` defaults to the full generation set, the safe choice + when the observation type is unknown. + 2. Hard guard: if the remaining new keys still exceed the physical + capacity (limit minus attributes already on the span), the excess new + keys are dropped from the tail. Reserved slots do not apply here, so + reserved attributes such as the output still land. + + Overwrites of existing keys are always kept, as they need no extra slot. + At most one warning is logged per write. Spans without SDK limits (e.g. + non-recording spans) are left untouched. Never raises. + """ + try: + max_attributes = getattr( + getattr(span, "_limits", None), "max_span_attributes", None + ) + if not isinstance(max_attributes, int): + return attributes + + existing = getattr(span, "attributes", None) or {} + new_keys = [ + key + for key, value in attributes.items() + if value is not None and key not in existing + ] + new_key_set = set(new_keys) + reserved_count = sum( + 1 for key in reserved_keys if key not in existing and key not in new_key_set + ) + if len(existing) + reserved_count + len(new_keys) <= max_attributes: + return attributes + + new_metadata_keys = [ + key for key in new_keys if _is_observation_metadata_key(key) + ] + free_metadata_slots = max( + 0, + max_attributes + - len(existing) + - reserved_count + - (len(new_keys) - len(new_metadata_keys)), + ) + dropped_metadata = new_metadata_keys[free_metadata_slots:] + dropped_metadata_set = set(dropped_metadata) + + remaining_new_keys = [ + key for key in new_keys if key not in dropped_metadata_set + ] + capacity = max(0, max_attributes - len(existing)) + dropped_other = remaining_new_keys[capacity:] + # The metadata budget is never looser than the hard guard, so only + # non-metadata keys can be dropped here. Count any metadata defensively. + dropped_metadata += [ + key for key in dropped_other if _is_observation_metadata_key(key) + ] + dropped_other = [ + key for key in dropped_other if not _is_observation_metadata_key(key) + ] + + if not dropped_metadata and not dropped_other: + return attributes + + _warn_dropped_attributes( + span_name=getattr(span, "name", None), + max_attributes=max_attributes, + dropped_metadata=dropped_metadata, + dropped_other=dropped_other, + ) + + dropped_keys = set(dropped_metadata) | set(dropped_other) + return { + key: value for key, value in attributes.items() if key not in dropped_keys + } + except Exception as e: + langfuse_logger.debug("Failed to apply span attribute limit: %s", e) + return attributes + + +def _warn_dropped_attributes( + *, + span_name: Optional[str], + max_attributes: int, + dropped_metadata: List[str], + dropped_other: List[str], +) -> None: + metadata_prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + "." + dropped_keys = ", ".join( + ( + [key.removeprefix(metadata_prefix) for key in dropped_metadata] + + dropped_other + )[:5] + ) + if not dropped_other: + langfuse_logger.warning( + "Dropped %s metadata key(s) from observation '%s' to stay within the " + "span attribute limit of %s (SpanLimits.max_span_attributes / " + "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT). Dropped keys include: %s", + len(dropped_metadata), + span_name, + max_attributes, + dropped_keys, + ) + return + + langfuse_logger.warning( + "Dropped %s metadata key(s) and %s other attribute(s) from observation " + "'%s' to stay within the span attribute limit of %s " + "(SpanLimits.max_span_attributes / OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT). " + "Dropped keys include: %s", + len(dropped_metadata), + len(dropped_other), + span_name, + max_attributes, + dropped_keys, + ) + + +def _set_span_attributes_within_limit( + span: otel_trace_api.Span, + attributes: Dict[str, Any], + reserved_keys: Tuple[str, ...] = _RESERVED_OBSERVATION_ATTRIBUTE_KEYS, +) -> None: + """Set attributes on a span without evicting existing attributes.""" + span.set_attributes( + _drop_attributes_over_span_limit(span, attributes, reserved_keys) + ) + + class LangfuseObservationWrapper: """Abstract base class for all Langfuse span types. @@ -121,8 +306,11 @@ def __init__( prompt: Associated prompt template from Langfuse prompt management """ self._otel_span = otel_span - self._otel_span.set_attribute( - LangfuseOtelSpanAttributes.OBSERVATION_TYPE, as_type + reserved_keys = _reserved_attribute_keys(as_type) + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.OBSERVATION_TYPE: as_type}, + reserved_keys, ) self._langfuse_client = langfuse_client self._observation_type = as_type @@ -137,14 +325,18 @@ def __init__( existing_environment or environment or self._langfuse_client._environment ) if self._environment is not None: - self._otel_span.set_attribute( - LangfuseOtelSpanAttributes.ENVIRONMENT, self._environment + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.ENVIRONMENT: self._environment}, + reserved_keys, ) self._release = release or self._langfuse_client._release if self._release is not None: - self._otel_span.set_attribute( - LangfuseOtelSpanAttributes.RELEASE, self._release + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.RELEASE: self._release}, + reserved_keys, ) # Handle media only if span is sampled @@ -203,8 +395,10 @@ def __init__( # We don't want to overwrite the observation type, and already set it attributes.pop(LangfuseOtelSpanAttributes.OBSERVATION_TYPE, None) - self._otel_span.set_attributes( - {k: v for k, v in attributes.items() if v is not None} + _set_span_attributes_within_limit( + self._otel_span, + {k: v for k, v in attributes.items() if v is not None}, + reserved_keys, ) # Set OTEL span status if level is ERROR self._set_otel_span_status_if_error( @@ -241,7 +435,11 @@ def set_trace_as_public(self) -> "LangfuseObservationWrapper": attributes = create_trace_attributes(public=True) - self._otel_span.set_attributes(attributes) + _set_span_attributes_within_limit( + self._otel_span, + attributes, + _reserved_attribute_keys(self._observation_type), + ) return self @@ -481,7 +679,11 @@ def _set_processed_span_attributes( ) ) - span.set_attributes(media_processed_attributes) + _set_span_attributes_within_limit( + span, + media_processed_attributes, + _reserved_attribute_keys(as_type or self._observation_type), + ) def _process_media_and_apply_mask( self, @@ -681,7 +883,11 @@ def update( ), ) - self._otel_span.set_attributes(attributes=attributes) + _set_span_attributes_within_limit( + self._otel_span, + attributes, + _reserved_attribute_keys(self._observation_type), + ) # Set OTEL span status if level is ERROR self._set_otel_span_status_if_error(level=level, status_message=status_message) diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index 718d854e7..438551fd6 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -41,6 +41,7 @@ _get_langfuse_trace_id_from_baggage, _get_propagated_attributes_from_context, ) +from langfuse._client.span import _set_span_attributes_within_limit from langfuse._client.span_exporter import LangfuseTransformingSpanExporter from langfuse._client.span_filter import ( is_app_root_eligible, @@ -243,7 +244,7 @@ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None } if propagated_attributes: - span.set_attributes(propagated_attributes) + _set_span_attributes_within_limit(span, propagated_attributes) langfuse_logger.debug( "Propagated %s attributes to span '%s': %s", @@ -339,7 +340,9 @@ def _mark_app_root_candidate(self, *, span: Span, parent_context: Context) -> No ) if mark_app_root: - span.set_attribute(LangfuseOtelSpanAttributes.IS_APP_ROOT, True) + _set_span_attributes_within_limit( + span, {LangfuseOtelSpanAttributes.IS_APP_ROOT: True} + ) def _cleanup_app_root_state(self, span: ReadableSpan) -> None: span_id = format_span_id(span.context.span_id) diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index 43d0856db..192577b6b 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -1,6 +1,7 @@ """Tests for ``langfuse.experiment`` — ``RunnerContext`` and ``RegressionError``.""" import inspect +import json import typing from datetime import datetime, timezone from typing import get_type_hints @@ -275,16 +276,12 @@ def failing_task(**kwargs): item_run.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_NAME] == result.run_name ) - assert ( - item_run.attributes[f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.run"] - == "metadata" - ) - assert ( - item_run.attributes[ - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.item" - ] - == "metadata" - ) + assert json.loads( + item_run.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_METADATA] + ) == {"run": "metadata"} + assert json.loads( + item_run.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA] + ) == {"item": "metadata"} assert ( item_run.attributes[ LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py new file mode 100644 index 000000000..4e871b17a --- /dev/null +++ b/tests/unit/test_metadata_attribute_limit.py @@ -0,0 +1,602 @@ +"""Langfuse writes must stay within the OTel span attribute limit. + +The Python OTel SDK evicts the oldest attribute once a span holds more than +``SpanLimits.max_span_attributes`` attributes, which used to drop Langfuse core +attributes written first. The SDK now drops the newest attributes instead, like +OTel JS: excess new metadata keys go first, then any other new keys that still +do not fit. Metadata also leaves room for the reserved observation attributes +that later updates may write. +""" + +import json +import logging +from datetime import datetime +from types import SimpleNamespace + +import pytest +from opentelemetry.trace import format_span_id + +from langfuse import propagate_attributes +from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.span import ( + _RESERVED_OBSERVATION_ATTRIBUTE_KEYS, + _RESERVED_SPAN_ATTRIBUTE_KEYS, + _drop_attributes_over_span_limit, + _reserved_attribute_keys, +) +from langfuse.api import DatasetItem, DatasetStatus + +METADATA_PREFIX = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + "." + + +def _metadata_keys(span): + return [key for key in span.attributes if key.startswith(METADATA_PREFIX)] + + +def _missing_reserved_keys(span): + observation_type = span.attributes.get(LangfuseOtelSpanAttributes.OBSERVATION_TYPE) + return [ + key + for key in _reserved_attribute_keys(observation_type) + if key not in span.attributes + ] + + +def _limit_warnings(caplog): + return [ + record + for record in caplog.records + if record.name == "langfuse" + and record.levelno == logging.WARNING + and "attribute limit" in record.getMessage() + ] + + +@pytest.fixture(autouse=True) +def default_attribute_limit(monkeypatch): + # Pin the OTel default so a custom limit in the environment cannot break + # the assertions below that expect 128. + monkeypatch.delenv("OTEL_ATTRIBUTE_COUNT_LIMIT", raising=False) + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "128") + + +@pytest.fixture +def small_limit_client(monkeypatch, request): + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "40") + return request.getfixturevalue("langfuse_memory_client") + + +def test_metadata_over_limit_at_start_keeps_core_attributes( + langfuse_memory_client, get_span, caplog +): + metadata = {f"key_{i}": f"value_{i}" for i in range(130)} + + generation = langfuse_memory_client.start_observation( + name="big-generation", + as_type="generation", + input="the input", + model="gpt-4o", + version="v1", + metadata=metadata, + ) + generation.end() + langfuse_memory_client.flush() + + span = get_span("big-generation") + attributes = span.attributes + + assert len(attributes) <= 128 + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o" + assert attributes[LangfuseOtelSpanAttributes.VERSION] == "v1" + + # Kept metadata keys are the first ones in insertion order. + kept = _metadata_keys(span) + assert kept == [f"{METADATA_PREFIX}key_{i}" for i in range(len(kept))] + assert 0 < len(kept) < 130 + + warnings = _limit_warnings(caplog) + assert len(warnings) == 1 + message = warnings[0].getMessage() + assert str(130 - len(kept)) in message + assert "128" in message + assert "big-generation" in message + assert f"key_{len(kept)}" in message + + +def test_update_over_limit_keeps_earlier_attributes( + langfuse_memory_client, get_span, caplog +): + span_wrapper = langfuse_memory_client.start_observation( + name="updated-span", + input="the input", + metadata={f"first_{i}": i for i in range(100)}, + ) + assert _limit_warnings(caplog) == [] + + span_wrapper.update( + output="the output", metadata={f"second_{i}": i for i in range(50)} + ) + span_wrapper.end() + langfuse_memory_client.flush() + + span = get_span("updated-span") + attributes = span.attributes + + assert len(attributes) + len(_missing_reserved_keys(span)) == 128 + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" + for i in range(100): + assert attributes[f"{METADATA_PREFIX}first_{i}"] == i + + second = [key for key in _metadata_keys(span) if ".second_" in key] + assert second == [f"{METADATA_PREFIX}second_{i}" for i in range(len(second))] + assert len(second) < 50 + + assert len(_limit_warnings(caplog)) == 1 + + +def test_update_current_span_over_limit_keeps_earlier_attributes( + langfuse_memory_client, get_span, caplog +): + with langfuse_memory_client.start_as_current_observation( + name="current-span", input="the input" + ): + langfuse_memory_client.update_current_span( + metadata={f"key_{i}": i for i in range(200)} + ) + langfuse_memory_client.flush() + + span = get_span("current-span") + assert span.dropped_attributes == 0 + assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert len(_limit_warnings(caplog)) == 1 + + +def test_overwriting_existing_metadata_keys_at_limit_is_allowed( + small_limit_client, get_span, caplog +): + span_wrapper = small_limit_client.start_observation( + name="overwrite-span", metadata={f"key_{i}": "old" for i in range(40)} + ) + span_at_start = span_wrapper._otel_span + assert ( + len(span_at_start.attributes) + len(_missing_reserved_keys(span_at_start)) == 40 + ) + kept = _metadata_keys(span_at_start) + caplog.clear() + + span_wrapper.update(metadata={key[len(METADATA_PREFIX) :]: "new" for key in kept}) + span_wrapper.end() + small_limit_client.flush() + + span = get_span("overwrite-span") + assert span.dropped_attributes == 0 + assert len(span.attributes) == len(span_at_start.attributes) + for key in kept: + assert span.attributes[key] == "new" + assert _limit_warnings(caplog) == [] + + +def test_custom_smaller_span_limit_is_respected(small_limit_client, get_span, caplog): + span_wrapper = small_limit_client.start_observation( + name="small-limit-span", + input="the input", + metadata={f"key_{i}": i for i in range(40)}, + ) + span_wrapper.end() + small_limit_client.flush() + + span = get_span("small-limit-span") + assert len(span.attributes) + len(_missing_reserved_keys(span)) == 40 + assert span.dropped_attributes == 0 + assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "span" + + warnings = _limit_warnings(caplog) + assert len(warnings) == 1 + assert "40" in warnings[0].getMessage() + + +def test_propagated_trace_attributes_count_toward_limit( + small_limit_client, get_span, caplog +): + with propagate_attributes( + user_id="user-1", + session_id="session-1", + metadata={f"trace_{i}": str(i) for i in range(5)}, + ): + span_wrapper = small_limit_client.start_observation( + name="propagated-span", + input="the input", + metadata={f"key_{i}": i for i in range(30)}, + ) + span_wrapper.end() + small_limit_client.flush() + + span = get_span("propagated-span") + attributes = span.attributes + assert len(attributes) + len(_missing_reserved_keys(span)) == 40 + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.TRACE_USER_ID] == "user-1" + assert attributes[LangfuseOtelSpanAttributes.TRACE_SESSION_ID] == "session-1" + for i in range(5): + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.trace_{i}"] + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert len(_limit_warnings(caplog)) == 1 + + +def test_metadata_under_limit_is_unchanged(langfuse_memory_client, get_span, caplog): + metadata = {f"key_{i}": i for i in range(50)} + span_wrapper = langfuse_memory_client.start_observation( + name="small-span", input="the input", metadata=metadata + ) + span_wrapper.update(metadata={"extra": "value"}) + span_wrapper.end() + langfuse_memory_client.flush() + + span = get_span("small-span") + assert span.dropped_attributes == 0 + assert len(_metadata_keys(span)) == 51 + for i in range(50): + assert span.attributes[f"{METADATA_PREFIX}key_{i}"] == i + assert span.attributes[f"{METADATA_PREFIX}extra"] == "value" + assert _limit_warnings(caplog) == [] + + +def test_later_updates_do_not_evict_attributes_after_metadata_cap( + langfuse_memory_client, get_span, caplog +): + generation = langfuse_memory_client.start_observation( + name="capped-generation", + as_type="generation", + input="the input", + model="gpt-4o", + metadata={f"key_{i}": i for i in range(130)}, + ) + generation.update(output="the output") + generation.update( + usage_details={"input": 10, "output": 20}, + cost_details={"input": 0.1, "output": 0.2}, + ) + generation.end() + langfuse_memory_client.flush() + + span = get_span("capped-generation") + attributes = span.attributes + + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT] is True + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" + assert LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS in attributes + assert LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS in attributes + assert len(attributes) + len(_missing_reserved_keys(span)) == 128 + assert len(_limit_warnings(caplog)) == 1 + + +def test_reserved_keys_already_written_cost_no_extra_slot(small_limit_client, get_span): + metadata = {f"key_{i}": i for i in range(50)} + reserved_values = { + "input": "the input", + "output": "the output", + "version": "v1", + "level": "WARNING", + "status_message": "careful", + } + + small_limit_client.start_observation(name="metadata-only", metadata=metadata).end() + small_limit_client.start_observation( + name="reserved-in-same-write", metadata=metadata, **reserved_values + ).end() + small_limit_client.start_observation( + name="reserved-already-on-span", **reserved_values + ).update(metadata=metadata).end() + small_limit_client.flush() + + kept_counts = { + name: len(_metadata_keys(get_span(name))) + for name in ( + "metadata-only", + "reserved-in-same-write", + "reserved-already-on-span", + ) + } + assert 0 < kept_counts["metadata-only"] < 50 + assert len(set(kept_counts.values())) == 1, kept_counts + + +def test_reserved_keys_depend_on_observation_type(langfuse_memory_client, get_span): + assert len(_RESERVED_SPAN_ATTRIBUTE_KEYS) == 7 + assert len(_RESERVED_OBSERVATION_ATTRIBUTE_KEYS) == 14 + for observation_type in ("generation", "embedding", None): + assert ( + _reserved_attribute_keys(observation_type) + == _RESERVED_OBSERVATION_ATTRIBUTE_KEYS + ) + for observation_type in ( + "span", + "agent", + "tool", + "chain", + "retriever", + "evaluator", + "guardrail", + "event", + ): + assert _reserved_attribute_keys(observation_type) == ( + _RESERVED_SPAN_ATTRIBUTE_KEYS + ) + + metadata = {f"key_{i}": i for i in range(130)} + for as_type in ("span", "generation"): + langfuse_memory_client.start_observation( + name=f"typed-{as_type}", + as_type=as_type, + input="the input", + metadata=metadata, + ).end() + langfuse_memory_client.flush() + + span = get_span("typed-span") + generation = get_span("typed-generation") + span_count = len(_metadata_keys(span)) + generation_count = len(_metadata_keys(generation)) + # The span reserves 5 open slots (level, status message, version, output, + # plus environment when unset) instead of 12 for the generation. + assert span_count - generation_count == 7 + assert span.dropped_attributes == 0 + assert generation.dropped_attributes == 0 + assert len(span.attributes) + len(_missing_reserved_keys(span)) == 128 + assert len(generation.attributes) + len(_missing_reserved_keys(generation)) == 128 + + +def test_generation_keeps_room_for_model_usage_and_output( + small_limit_client, get_span, caplog +): + generation = small_limit_client.start_observation( + name="roomy-generation", + as_type="generation", + input="the input", + metadata={f"key_{i}": i for i in range(40)}, + ) + caplog.clear() + generation.update( + output="the output", + model="gpt-4o", + usage_details={"input": 1}, + cost_details={"input": 0.1}, + model_parameters={"temperature": 0}, + ) + generation.end() + small_limit_client.flush() + + span = get_span("roomy-generation") + attributes = span.attributes + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o" + assert LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS in attributes + assert LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS in attributes + assert LangfuseOtelSpanAttributes.OBSERVATION_MODEL_PARAMETERS in attributes + assert _limit_warnings(caplog) == [] + + +def test_warning_message_format(small_limit_client, caplog): + small_limit_client.start_observation( + name="warned-span", metadata={f"key_{i}": i for i in range(40)} + ).end() + + warnings = _limit_warnings(caplog) + assert len(warnings) == 1 + message = warnings[0].getMessage() + assert message.startswith("Dropped ") + assert ( + " metadata key(s) from observation 'warned-span' to stay within the span " + "attribute limit of 40 (SpanLimits.max_span_attributes / " + "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT). Dropped keys include: " + ) in message + dropped_keys = message.split("Dropped keys include: ")[1].split(", ") + assert len(dropped_keys) == 5 + assert all(key.startswith("key_") for key in dropped_keys) + + +def test_new_attributes_beyond_capacity_drop_newest_instead_of_evicting( + small_limit_client, get_span, caplog +): + with small_limit_client.start_as_current_observation( + name="full-generation", + as_type="generation", + input="the input", + model="gpt-4o", + metadata={f"key_{i}": i for i in range(40)}, + ) as generation: + generation.update(output="the output", usage_details={"input": 1}) + otel_span = generation._otel_span + free_slots = 40 - len(otel_span.attributes) + assert 0 < free_slots < 20 + attributes_before = dict(otel_span.attributes) + caplog.clear() + + with propagate_attributes( + metadata={f"trace_{i:02d}": str(i) for i in range(20)} + ): + pass + small_limit_client.flush() + + span = get_span("full-generation") + attributes = span.attributes + + assert span.dropped_attributes == 0 + assert len(attributes) == 40 + for key, value in attributes_before.items(): + assert attributes[key] == value + assert attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT] is True + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" + + trace_prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + "." + kept_trace_keys = [key for key in attributes if key.startswith(trace_prefix)] + assert kept_trace_keys == [ + f"{trace_prefix}trace_{i:02d}" for i in range(free_slots) + ] + + warnings = _limit_warnings(caplog) + assert len(warnings) == 1 + message = warnings[0].getMessage() + assert f"0 metadata key(s) and {20 - free_slots} other attribute(s)" in message + assert f"{trace_prefix}trace_{free_slots:02d}" in message + + +def _fake_span(limit, existing): + return SimpleNamespace( + name="fake", + attributes=dict(existing), + _limits=SimpleNamespace(max_span_attributes=limit), + ) + + +def test_hard_guard_keeps_overwrites_and_drops_new_tail(caplog): + span = _fake_span(5, {f"a_{i}": i for i in range(4)}) + + result = _drop_attributes_over_span_limit( + span, + { + "a_0": "overwritten", + "b_0": 0, + f"{METADATA_PREFIX}m": 1, + "b_1": 1, + "b_2": 2, + }, + ) + + assert result == {"a_0": "overwritten", "b_0": 0} + message = _limit_warnings(caplog)[0].getMessage() + assert "1 metadata key(s) and 2 other attribute(s)" in message + assert "Dropped keys include: m, b_1, b_2" in message + + +def test_hard_guard_never_raises(): + class BrokenAttributes: + def __contains__(self, key): + raise RuntimeError("boom") + + def __len__(self): + return 1 + + span = SimpleNamespace( + attributes=BrokenAttributes(), + _limits=SimpleNamespace(max_span_attributes=1), + ) + attributes = {"key": "value"} + + assert _drop_attributes_over_span_limit(span, attributes) is attributes + + +def test_experiment_with_large_metadata_keeps_output_and_experiment_attributes( + langfuse_memory_client, get_span, find_spans, caplog +): + item_metadata = { + "experiment_name": "user-value", + "nested": {"a": 1, "b": {"c": "d"}}, + **{f"item_{i}": str(i) for i in range(150)}, + } + item = DatasetItem( + id="item-1", + status=DatasetStatus.ACTIVE, + input="question", + expected_output="answer", + metadata=item_metadata, + source_trace_id=None, + source_observation_id=None, + dataset_id="dataset-1", + dataset_name="Dataset", + created_at=datetime.now(), + updated_at=datetime.now(), + media_references=[], + ) + + def task(**kwargs): + langfuse_memory_client.start_observation(name="task-child").end() + return "the answer" + + result = langfuse_memory_client.run_experiment( + name="big-metadata-experiment", + data=[item], + task=task, + metadata={"run": "metadata"}, + max_concurrency=1, + ) + langfuse_memory_client.flush() + + task_span = get_span("experiment-item-task") + task_span_id = format_span_id(task_span.context.span_id) + attributes = task_span.attributes + assert task_span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "question" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the answer" + + # Copied experiment metadata is one JSON attribute each, never trimmed. + assert ( + json.loads(attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA]) + == item_metadata + ) + assert json.loads(attributes[LangfuseOtelSpanAttributes.EXPERIMENT_METADATA]) == { + "run": "metadata" + } + assert not [ + key + for key in attributes + if key.startswith(LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA + ".") + ] + assert attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ID] == result.experiment_id + assert attributes[LangfuseOtelSpanAttributes.EXPERIMENT_NAME] == result.run_name + assert attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID] == "item-1" + assert ( + attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID] + == task_span_id + ) + + # Observation metadata is trimmed, but the run keys survive and the run + # value wins over the user key of the same name. + assert 0 < len(_metadata_keys(task_span)) < len(item_metadata) + 4 + assert attributes[f"{METADATA_PREFIX}experiment_name"] == "big-metadata-experiment" + assert attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name + assert attributes[f"{METADATA_PREFIX}dataset_id"] == "dataset-1" + assert attributes[f"{METADATA_PREFIX}dataset_item_id"] == "item-1" + assert f"{METADATA_PREFIX}item_149" not in attributes + + item_run = get_span("experiment-item-run") + assert item_run.dropped_attributes == 0 + assert item_run.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == ( + "the answer" + ) + assert ( + item_run.attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name + ) + assert LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA in item_run.attributes + + (child,) = find_spans("task-child") + assert child.dropped_attributes == 0 + assert len(child.attributes) < 30 + assert ( + json.loads( + child.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA] + ) + == item_metadata + ) + assert ( + child.attributes[LangfuseOtelSpanAttributes.EXPERIMENT_METADATA] + == attributes[LangfuseOtelSpanAttributes.EXPERIMENT_METADATA] + ) + + # Only the observation metadata of the task and item-run spans is trimmed. + warnings = _limit_warnings(caplog) + assert warnings + assert all("other attribute(s)" not in w.getMessage() for w in warnings) diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index b16a89844..024bac64f 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -6,6 +6,7 @@ """ import concurrent.futures +import json from datetime import datetime import pytest @@ -19,6 +20,14 @@ from tests.unit.test_otel import TestOTelBase +def _assert_json_attribute(span_data, key, expected): + """Assert a metadata attribute is one JSON string, not one attribute per key.""" + attributes = span_data["attributes"] + assert key in attributes, f"Attribute {key} not found in {attributes}" + assert json.loads(attributes[key]) == expected + assert not [k for k in attributes if k.startswith(f"{key}.")] + + class TestPropagateAttributesBase(TestOTelBase): """Base class for propagate_attributes tests with shared helper methods.""" @@ -2718,22 +2727,16 @@ def task_with_child_spans(*, item, **kwargs): LangfuseOtelSpanAttributes.EXPERIMENT_NAME, result.run_name, ) - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - first_root, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - metadata_value, - ) - - self.verify_span_attribute( + _assert_json_attribute( first_root, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.item_type", - "test", + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, ) - self.verify_span_attribute( + + _assert_json_attribute( first_root, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.priority", - "high", + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + {"item_type": "test", "priority": "high"}, ) # Environment should be set to sdk-experiment @@ -2768,12 +2771,11 @@ def task_with_child_spans(*, item, **kwargs): LangfuseOtelSpanAttributes.EXPERIMENT_NAME, result.run_name, ) - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - metadata_value, - ) + _assert_json_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, + ) self.verify_span_attribute( child_span, LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID, @@ -2982,12 +2984,11 @@ def task_with_children(*, item, **kwargs): ) # Should have experiment metadata - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - first_root, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - metadata_value, - ) + _assert_json_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, + ) # Environment should be set to sdk-experiment self.verify_span_attribute( @@ -3019,23 +3020,17 @@ def task_with_children(*, item, **kwargs): ) # Experiment metadata should be propagated - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - metadata_value, - ) - - # Item metadata should be propagated - self.verify_span_attribute( + _assert_json_attribute( child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.source", - "dataset", + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, ) - self.verify_span_attribute( + + # Item metadata should be propagated + _assert_json_attribute( child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.index", - "0", + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + {"source": "dataset", "index": 0}, ) # Environment should be propagated to children @@ -3140,8 +3135,6 @@ def task_with_nested_spans(*, item, **kwargs): def test_experiment_metadata_merging(self, langfuse_client, memory_exporter): """Test that experiment metadata and item metadata are both propagated correctly.""" - from langfuse._client.attributes import _serialize - # Rich metadata experiment_metadata = { "experiment_type": "A/B test", @@ -3188,21 +3181,19 @@ def task_with_child(*, item, **kwargs): # Verify child span has both experiment and item metadata propagated child_span = self.get_span_by_name(memory_exporter, "metadata-child") - # Verify experiment metadata is flattened and propagated - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - _serialize(metadata_value), - ) + # Verify experiment metadata is propagated as one JSON attribute + _assert_json_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, + ) - # Verify item metadata is flattened and propagated - for metadata_key, metadata_value in item_metadata.items(): - self.verify_span_attribute( - child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA}.{metadata_key}", - _serialize(metadata_value), - ) + # Verify item metadata is propagated as one JSON attribute + _assert_json_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + item_metadata, + ) # Verify environment is propagated to child self.verify_span_attribute( @@ -3214,7 +3205,7 @@ def task_with_child(*, item, **kwargs): def test_experiment_metadata_values_are_validated_individually( self, langfuse_client, memory_exporter, caplog ): - """Experiment metadata is flattened so large combined dicts still propagate.""" + """Experiment metadata is one JSON attribute that skips the 200-char limit.""" caplog.set_level("WARNING", logger="langfuse") @@ -3242,17 +3233,12 @@ def task_with_child(*, item, **kwargs): child_span = self.get_span_by_name(memory_exporter, "large-metadata-child") - for metadata_key, metadata_value in experiment_metadata.items(): - self.verify_span_attribute( - child_span, - f"{LangfuseOtelSpanAttributes.EXPERIMENT_METADATA}.{metadata_key}", - metadata_value, - ) - - self.verify_missing_attribute( + _assert_json_attribute( child_span, LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + experiment_metadata, ) + assert "experiment_metadata' value is over 200 characters" not in caplog.text From e99cc02f4f942fa62807839f58d4787558b307b6 Mon Sep 17 00:00:00 2001 From: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:20:26 +0200 Subject: [PATCH 16/31] fix(tracing): mask dict metadata per key when the mask function fails (#1956) * fix(tracing): mask metadata per key when the mask function fails When a custom mask function raised on dict metadata, the fallback string was written to the bare langfuse.observation.metadata attribute. The server parses that attribute as a JSON object, so the fallback was lost, and nothing showed which keys had been masked. For dict metadata, write the fallback under each key of the failed call instead, so it merges with keys from earlier updates like any other update. Non-dict metadata, input and output keep the plain-string fallback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tracing): apply mask to propagated trace metadata propagate_attributes now applies the client's mask to each propagated trace metadata value (keys are kept), on the raw value before serialization and the 200 character check. If the mask raises, only that value becomes the fallback string. The masked values are stored in the context and baggage, so the current span, child spans, and outgoing baggage all carry them. Matches langfuse-js #1003. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(tracing): document which client's mask applies to propagated metadata The mask for propagated trace metadata comes from the client for the public key in the execution context, otherwise the only initialized client. With several clients and no public key in context, or with no client yet, propagated metadata is not masked. Document this and the workarounds, and pin the behavior with a test. Co-Authored-By: Claude Opus 5.5 (1M context) * revert(tracing): do not mask propagated trace metadata Propagated metadata is passed explicitly by the developer. Masking it adds client-resolution ambiguity and baggage/length-limit ordering complexity, and diverges from the JS SDK. Co-authored-by: Hassieb Pakzad * fix(tracing): keep string fallback when the mask fails on empty dict metadata Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/constants.py | 2 + langfuse/_client/span.py | 21 ++++++- tests/unit/test_otel.py | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/langfuse/_client/constants.py b/langfuse/_client/constants.py index c2d0aa7aa..95968507a 100644 --- a/langfuse/_client/constants.py +++ b/langfuse/_client/constants.py @@ -11,6 +11,8 @@ LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT = "sdk-experiment" +MASK_FALLBACK_VALUE = "" + """Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat""" ObservationTypeGenerationLike: TypeAlias = Literal[ "generation", diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 0c1d6db90..21bb9fabb 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -46,6 +46,7 @@ create_trace_attributes, ) from langfuse._client.constants import ( + MASK_FALLBACK_VALUE, ObservationTypeGenerationLike, ObservationTypeLiteral, ObservationTypeLiteralNoEvent, @@ -706,10 +707,17 @@ def _process_media_and_apply_mask( The processed and masked data """ return self._mask_attribute( - data=self._process_media_in_attribute(data=data, field=field) + data=self._process_media_in_attribute(data=data, field=field), field=field ) - def _mask_attribute(self, *, data: Any) -> Any: + def _mask_attribute( + self, + *, + data: Any, + field: Optional[ + Union[Literal["input"], Literal["output"], Literal["metadata"]] + ] = None, + ) -> Any: """Apply the configured mask function to data. Internal method that applies the client's configured masking function to @@ -717,6 +725,7 @@ def _mask_attribute(self, *, data: Any) -> Any: Args: data: The data to mask + field: The attribute the data belongs to Returns: The masked data, or the original data if no mask is configured @@ -733,7 +742,13 @@ def _mask_attribute(self, *, data: Any) -> Any: e, ) - return "" + # Dict metadata is written per key, so mask each key instead of + # writing a plain string to the bare metadata attribute. An empty dict + # has no keys to mask, so it keeps the plain string to stay visible + if field == "metadata" and isinstance(data, dict) and data: + return {key: MASK_FALLBACK_VALUE for key in data} + + return MASK_FALLBACK_VALUE def _process_media_in_attribute( self, diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 6c4b23d3e..286a5e4d0 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -1980,6 +1980,108 @@ def update_random_metadata(thread_id): assert "version" in system_data assert "features" in system_data + MASK_FALLBACK = "" + + def get_metadata_attributes(self, span_data: dict) -> dict: + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + return { + key: value + for key, value in span_data["attributes"].items() + if key == prefix or key.startswith(f"{prefix}.") + } + + def test_failed_mask_on_update_masks_each_metadata_key( + self, configurable_langfuse_client, memory_exporter + ): + def mask(*, data, **kwargs): + if isinstance(data, dict) and "secret" in data: + raise ValueError("mask failed") + return data + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation(name="mask-fail", metadata={"a": 1}) + span.update(metadata={"secret": "pw", "b": 2}) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-fail")[0] + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + assert self.get_metadata_attributes(span_data) == { + f"{prefix}.a": 1, + f"{prefix}.secret": self.MASK_FALLBACK, + f"{prefix}.b": self.MASK_FALLBACK, + } + + def test_failed_mask_on_start_masks_each_metadata_key( + self, configurable_langfuse_client, memory_exporter + ): + def mask(*, data, **kwargs): + if isinstance(data, dict): + raise ValueError("mask failed") + return data + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation( + name="mask-fail-start", metadata={"secret": "pw", "b": 2} + ) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-fail-start")[0] + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + assert self.get_metadata_attributes(span_data) == { + f"{prefix}.secret": self.MASK_FALLBACK, + f"{prefix}.b": self.MASK_FALLBACK, + } + + def test_failed_mask_keeps_string_fallback_for_empty_dict_metadata( + self, configurable_langfuse_client, memory_exporter + ): + def mask(*, data, **kwargs): + if isinstance(data, dict): + raise ValueError("mask failed") + return data + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation( + name="mask-fail-empty-dict", metadata={} + ) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-fail-empty-dict")[0] + assert self.get_metadata_attributes(span_data) == { + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + } + + def test_failed_mask_keeps_string_fallback_for_non_dict_values( + self, configurable_langfuse_client, memory_exporter + ): + def mask(*, data, **kwargs): + if data is not None: + raise ValueError("mask failed") + return data + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation( + name="mask-fail-non-dict", + input={"secret": "pw"}, + output={"secret": "pw"}, + metadata="plain-string", + ) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-fail-non-dict")[0] + attributes = span_data["attributes"] + assert self.get_metadata_attributes(span_data) == { + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + } + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] + == self.MASK_FALLBACK + ) + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] + == self.MASK_FALLBACK + ) + class TestMultiProjectSetup(TestOTelBase): """Tests for multi-project setup within the same process. From edf44eb7ea94982e82ad89279c6b64ba5ceaeb37 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:01:29 +0400 Subject: [PATCH 17/31] test(api): pin how POST /scores responses parse into the union (#1964) * test(api): pin how POST /scores responses parse into the union Cover the 200, 202 and 207 response shapes of scores.create, sync and async, against a mocked transport. CI also runs them on pydantic 2.7, whose union matching depends on the member order. Co-authored-by: Hassieb Pakzad * test(api): assert the pydantic 2.7 overlay and describe its coverage Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- .github/workflows/ci.yml | 8 ++ tests/unit/test_scores_batch_response.py | 110 +++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/unit/test_scores_batch_response.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee355e49b..be8c9be1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,14 @@ jobs: python --version uv run --frozen pytest -n auto --dist worksteal -s -v --log-cli-level=INFO tests/unit + # pydantic < 2.8 matches undiscriminated unions left to right, so the + # CreateScoresResponse member order matters there. + - name: Run score response parsing on pydantic 2.7 + if: ${{ matrix.python-version == '3.11' }} + run: | + uv run --frozen --with "pydantic==2.7.4" python -c "import pydantic; assert pydantic.VERSION == '2.7.4', pydantic.VERSION" + uv run --frozen --with "pydantic==2.7.4" pytest -v tests/unit/test_scores_batch_response.py + e2e-tests: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/tests/unit/test_scores_batch_response.py b/tests/unit/test_scores_batch_response.py new file mode 100644 index 000000000..c52eab692 --- /dev/null +++ b/tests/unit/test_scores_batch_response.py @@ -0,0 +1,110 @@ +"""Parsing of POST /api/public/scores responses into the CreateScoresResponse union. + +The union is undiscriminated, so the model is picked from the response body. CI +also runs this file on pydantic 2.7, an older supported minor whose union matching +differs from current releases. +""" + +import json +from typing import Any, List + +import httpx +import pytest + +from langfuse.api import AsyncLangfuseAPI, LangfuseAPI +from langfuse.api.scores.types.create_score_batch_response import ( + CreateScoreBatchResponse, +) +from langfuse.api.scores.types.create_score_batch_results import ( + CreateScoreBatchResults, +) +from langfuse.api.scores.types.create_score_request import CreateScoreRequest +from langfuse.api.scores.types.create_score_response import CreateScoreResponse + +SINGLE_REQUEST = CreateScoreRequest(name="accuracy", value=0.9, trace_id="trace-1") +BATCH_REQUEST = [ + CreateScoreRequest(name="accuracy", value=0.9, trace_id="trace-1"), + CreateScoreRequest(name="tone", value="friendly", trace_id="trace-1"), +] + +CASES = [ + pytest.param(SINGLE_REQUEST, 200, {"id": "score-1"}, id="single-200"), + pytest.param(BATCH_REQUEST, 202, {"message": "Accepted"}, id="batch-202"), + pytest.param( + BATCH_REQUEST, + 207, + { + "accepted": 1, + "rejected": 1, + "errors": [{"message": "tone: value must be numeric"}], + }, + id="batch-207", + ), +] + + +def _handler(status: int, body: dict, requests: List[httpx.Request]) -> Any: + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json=body) + + return handle + + +def _assert_parsed(response: Any, status: int, body: dict) -> None: + if status == 200: + assert isinstance(response, CreateScoreResponse) + assert response.id == body["id"] + elif status == 202: + assert isinstance(response, CreateScoreBatchResponse) + assert response.message == "Accepted" + else: + assert isinstance(response, CreateScoreBatchResults) + assert (response.accepted, response.rejected) == (1, 1) + assert [error.message for error in response.errors] == [ + "tone: value must be numeric" + ] + + +def _assert_sent(requests: List[httpx.Request], request: Any) -> None: + assert len(requests) == 1 + assert requests[0].url.path == "/api/public/scores" + sent = json.loads(requests[0].content) + assert isinstance(sent, list) == isinstance(request, list) + + +@pytest.mark.parametrize(("request_body", "status", "body"), CASES) +def test_sync_create_parses_each_response_shape(request_body, status, body): + requests: List[httpx.Request] = [] + client = LangfuseAPI( + base_url="http://langfuse.test", + username="pk", + password="sk", + httpx_client=httpx.Client( + transport=httpx.MockTransport(_handler(status, body, requests)) + ), + ) + + response = client.scores.create(request=request_body) + + _assert_parsed(response, status, body) + _assert_sent(requests, request_body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("request_body", "status", "body"), CASES) +async def test_async_create_parses_each_response_shape(request_body, status, body): + requests: List[httpx.Request] = [] + client = AsyncLangfuseAPI( + base_url="http://langfuse.test", + username="pk", + password="sk", + httpx_client=httpx.AsyncClient( + transport=httpx.MockTransport(_handler(status, body, requests)) + ), + ) + + response = await client.scores.create(request=request_body) + + _assert_parsed(response, status, body) + _assert_sent(requests, request_body) From 988195e3f79aad43503043e2e399858a348ba8a7 Mon Sep 17 00:00:00 2001 From: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:00:19 +0200 Subject: [PATCH 18/31] feat(tracing)!: JSON-encode observation and trace metadata values (#1958) * fix(tracing): JSON-encode metadata attribute values Per-key `langfuse.observation.metadata.` attributes now carry `json.dumps(value, cls=EventSerializer)` for every value, strings included. Previously str and int were passed raw/natively, so ints beyond int64 made the OTLP encoder drop the key, ints beyond 2^53 lost precision on the server, and a server that JSON-decodes values could not tell the string "123" from the number 123. None values are still skipped so updates keep earlier keys. The bare non-dict metadata attribute is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(tracing)!: JSON-encode non-dict metadata too The bare `langfuse.observation.metadata` attribute (non-dict metadata) is now sent as `json.dumps(metadata, cls=EventSerializer)`, strings included, matching the per-key values and langfuse-js #1005. Previously strings were passed through raw, so `metadata="foo"` sent `foo` in Python but `"foo"` in JS. None is still not written. Input/output and other `_serialize` callers are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * test(e2e): match metadata expectations to server decoding The server JSON-decodes per-key metadata values only for Python SDK major >= 5 (langfuse/langfuse#18436). On 4.x the experiment items API returns the raw JSON string, so compare against the encoded value until the version bump. test_concurrency now writes an int so it round-trips the same way with and without server-side decoding. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tracing): write metadata JSON with compact separators and raw non-ASCII Encode observation and trace metadata values with separators=(",", ":") and ensure_ascii=False so the output is byte-identical to JS JSON.stringify, matching propagated metadata (#1932). Co-Authored-By: Claude Opus 5.5 (1M context) * feat(tracing): debug-log metadata keys skipped because their value is None Co-Authored-By: Claude Opus 5.5 (1M context) * feat(tracing)!: JSON-encode propagated trace metadata like observation metadata propagate_attributes(metadata=...) now uses the same encoder as observation metadata, so a string such as "123" stays a string once the server decodes langfuse.trace.metadata.. Ints outside the JS-safe range and NaN/Infinity become JSON strings instead of exact digits or being dropped. The 200 character limit applies to the encoded value. Plain str values skip EventSerializer and go straight to json.dumps; the output is byte-identical and pinned against EventSerializer and JSON.stringify. Co-authored-by: Hassieb Pakzad * fix(media): extract top-level data URIs from JSON-encoded metadata values Metadata values are JSON-encoded, so a top-level data URI arrives quoted and the exporter no longer recognized it, shipping the base64 inline. Decode the JSON string for Langfuse metadata attributes, process the media, and re-encode the reference string. Co-authored-by: Hassieb Pakzad * style(tests): format span attribute limit test Co-authored-by: Hassieb Pakzad * fix(tracing): drop None propagated metadata values with a warning Co-authored-by: Hassieb Pakzad * perf(media): extract JSON-encoded metadata data URIs without json.loads Strip the quotes of a JSON-encoded top-level data URI in place instead of decoding and re-encoding it. Values with backslashes, inner quotes, or no ;base64 header in the first 256 characters are left unchanged. Co-authored-by: Hassieb Pakzad --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- langfuse/_client/attributes.py | 43 +++- langfuse/_client/propagation.py | 67 ++---- langfuse/_client/span_exporter.py | 54 +++++ tests/e2e/test_core_sdk.py | 2 +- tests/e2e/test_experiments.py | 11 +- tests/support/utils.py | 14 ++ tests/unit/test_langchain.py | 2 +- tests/unit/test_mask_otel_spans.py | 111 +++++++++ tests/unit/test_metadata_attribute_limit.py | 24 +- tests/unit/test_openai.py | 14 +- tests/unit/test_otel.py | 238 ++++++++++++++++++-- tests/unit/test_propagate_attributes.py | 234 +++++++++++++------ 12 files changed, 639 insertions(+), 175 deletions(-) diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index c744dfa82..c3ca70a09 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -20,6 +20,7 @@ ) from langfuse._utils.serializer import EventSerializer from langfuse.api import MapValue +from langfuse.logger import langfuse_logger from langfuse.model import PromptClient from langfuse.types import SpanLevel @@ -160,6 +161,26 @@ def _serialize(obj: Any) -> Optional[str]: return json.dumps(obj, cls=EventSerializer) +def _serialize_metadata_value(value: Any) -> str: + """JSON-encode one metadata value, strings included. + + The server decodes metadata values from v5 SDKs, so encoding every value + keeps "123" and 123 distinct. EventSerializer turns ints outside the + JS-safe range and NaN/Infinity into JSON strings and never raises, so one + bad value cannot drop the other keys. Compact separators and raw non-ASCII + match JS JSON.stringify. + """ + # Plain strings skip EventSerializer, which returns them unchanged and then + # uses the same string encoder, so the output is identical but cheaper. + # str subclasses (e.g. str enums) still go through EventSerializer. + if type(value) is str: + return json.dumps(value, ensure_ascii=False) + + return json.dumps( + value, cls=EventSerializer, separators=(",", ":"), ensure_ascii=False + ) + + def _flatten_and_serialize_metadata( metadata: Any, type: Literal["observation", "trace"] ) -> dict: @@ -169,16 +190,24 @@ def _flatten_and_serialize_metadata( else LangfuseOtelSpanAttributes.TRACE_METADATA ) - metadata_attributes: Dict[str, Union[str, int, None]] = {} + metadata_attributes: Dict[str, Optional[str]] = {} if not isinstance(metadata, dict): - metadata_attributes[prefix] = _serialize(metadata) + # JSON-encode non-dict metadata too, strings included, matching the + # per-key values below. None stays None so it is not written. + metadata_attributes[prefix] = ( + None if metadata is None else _serialize_metadata_value(metadata) + ) else: for key, value in metadata.items(): - metadata_attributes[f"{prefix}.{key}"] = ( - value - if isinstance(value, str) or isinstance(value, int) - else _serialize(value) - ) + # Skip None so an update does not overwrite an earlier value. + if value is None: + langfuse_logger.debug( + 'Observation metadata key "%s" was not written because its value is None', + key, + ) + continue + + metadata_attributes[f"{prefix}.{key}"] = _serialize_metadata_value(value) return metadata_attributes diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index a79f57260..d36ca534a 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,7 +5,6 @@ propagate to all child spans within the context. """ -import math import re from typing import ( Any, @@ -39,10 +38,12 @@ _agnosticcontextmanager, ) -from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.attributes import ( + LangfuseOtelSpanAttributes, + _serialize_metadata_value, +) from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT from langfuse._client.span import _set_span_attributes_within_limit -from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger from langfuse.model import PromptClient @@ -156,8 +157,8 @@ def propagate_attributes( within a user session (e.g., a conversation thread, multi-turn interaction). metadata: Additional key-value metadata to propagate to all spans. - Keys must be US-ASCII strings - - Values are coerced to strings - - Coerced values must be ≤200 characters + - Values are JSON-encoded, strings included + - Encoded values must be ≤200 characters - Use for dimensions like internal correlating identifiers - AVOID: large payloads or sensitive data version: Version identfier for parts of your application that are independently versioned, e.g. agents @@ -284,11 +285,13 @@ def propagate_attributes( - **Validation**: Attribute values (user_id, session_id, version, tags, trace_name) must be strings ≤200 characters. Environment must also match Langfuse's environment format: lowercase alphanumeric with optional - hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Non-string - metadata values are serialized like JavaScript's `JSON.stringify` - (compact separators, non-ASCII kept as is, None becomes "null", - integers keep their exact digits) before the 200 character limit is - applied. Values containing NaN or Infinity are dropped. + hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Metadata + values, strings included, are JSON-encoded the same way as + observation metadata (compact separators, non-ASCII kept as is, + integers outside the JavaScript safe range and NaN/Infinity become + JSON strings) before the 200 character limit is applied, so `"123"` + stays a string on the server. None values are dropped with a warning, + like observation metadata skips them. Invalid values will be dropped with a warning logged. - **OpenTelemetry**: This uses OpenTelemetry context propagation under the hood, making it compatible with other OTel-instrumented libraries. @@ -388,17 +391,16 @@ def _propagate_attributes( validated_metadata: Dict[str, str] = {} for key, value in metadata_value.items(): - serialized_value = _serialize_propagated_metadata_value(value) - - if serialized_value is None: + if value is None: langfuse_logger.warning( - "Propagated attribute '%s.%s' contains NaN or Infinity, which " - "is not valid JSON. Dropping value.", + "Propagated attribute '%s.%s' is None. Dropping value.", metadata_key, key, ) continue + serialized_value = _serialize_metadata_value(value) + if _validate_string_value( value=serialized_value, key=f"{metadata_key}.{key}" ): @@ -659,41 +661,6 @@ def _validate_propagated_value( return value -class _PropagatedMetadataSerializer(EventSerializer): - """EventSerializer variant that matches the JS SDK for propagated metadata. - - Integers keep their exact digits as JSON numbers at any depth, and values - containing NaN or Infinity are flagged so the caller can drop them. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.found_non_finite_number = False - - def default(self, obj: Any) -> Any: - if isinstance(obj, int) and not isinstance(obj, bool): - return obj - - if isinstance(obj, float) and not math.isfinite(obj): - self.found_non_finite_number = True - return None - - return super().default(obj) - - -def _serialize_propagated_metadata_value(value: Any) -> Optional[str]: - """Serialize like JSON.stringify in the JS SDK; None means drop the value.""" - if isinstance(value, str): - return value - - serializer = _PropagatedMetadataSerializer( - separators=(",", ":"), ensure_ascii=False - ) - serialized = serializer.encode(value) - - return None if serializer.found_non_finite_number else serialized - - def _validate_string_value(*, value: str, key: str) -> bool: if not isinstance(value, str): langfuse_logger.warning( # type: ignore diff --git a/langfuse/_client/span_exporter.py b/langfuse/_client/span_exporter.py index efbb04261..ccad5e843 100644 --- a/langfuse/_client/span_exporter.py +++ b/langfuse/_client/span_exporter.py @@ -88,6 +88,9 @@ "llm.output_messages", ) +# Includes the leading quote of a JSON-encoded data URI. +_MAX_DATA_URI_HEADER_LENGTH = 256 + class LangfuseTransformingSpanExporter(SpanExporter): """Apply Langfuse export-stage transformations before delegating export.""" @@ -211,6 +214,13 @@ def _process_media_string( attribute_key: str, value: str, ) -> str: + if value.startswith('"data:') and _is_langfuse_metadata_attribute( + attribute_key + ): + return self._process_json_encoded_media_string( + span=span, attribute_key=attribute_key, value=value + ) + media_manager = cast(MediaManager, self._media_manager) field = _media_field_for_attribute(attribute_key) @@ -260,6 +270,40 @@ def _process_media_string( return _serialize_media_value(processed_json_value, fallback=value) + def _process_json_encoded_media_string( + self, + *, + span: ReadableSpan, + attribute_key: str, + value: str, + ) -> str: + # Langfuse metadata values are JSON-encoded, so a top-level data URI + # arrives quoted. A JSON string without backslashes holds its characters + # literally, so stripping the outer quotes yields the data URI without + # json.loads. The media reference is built from that backslash- and + # quote-free data URI, so quoting it again is valid JSON. Escaped or + # malformed values are left unchanged. + header_end = value.find(",", 0, _MAX_DATA_URI_HEADER_LENGTH) + + if ( + header_end == -1 + or not value.endswith('"') + or not value[:header_end].endswith(";base64") + or "\\" in value + or value.find('"', 1, -1) != -1 + ): + return value + + data_uri = value[1:-1] + processed_value = self._process_media_string( + span=span, attribute_key=attribute_key, value=data_uri + ) + + if processed_value == data_uri: + return value + + return f'"{processed_value}"' + def _apply_mask_otel_spans( self, *, @@ -556,6 +600,16 @@ def _is_attribute_sequence(value: AttributeValue) -> bool: return isinstance(value, SequenceCollection) and not isinstance(value, (str, bytes)) +def _is_langfuse_metadata_attribute(attribute_key: str) -> bool: + return any( + attribute_key == prefix or attribute_key.startswith(f"{prefix}.") + for prefix in ( + LangfuseOtelSpanAttributes.OBSERVATION_METADATA, + LangfuseOtelSpanAttributes.TRACE_METADATA, + ) + ) + + def _is_base64_data_uri(value: str) -> bool: if not value.startswith("data:") or "," not in value: return False diff --git a/tests/e2e/test_core_sdk.py b/tests/e2e/test_core_sdk.py index 5330932c3..6d80f821e 100644 --- a/tests/e2e/test_core_sdk.py +++ b/tests/e2e/test_core_sdk.py @@ -37,7 +37,7 @@ async def update_generation(i, langfuse: Langfuse): ) # Update generation with metadata - generation.update(metadata={"count": str(i)}) + generation.update(metadata={"count": i}) # End the generation generation.end() diff --git a/tests/e2e/test_experiments.py b/tests/e2e/test_experiments.py index ceddba7be..4e3c7c125 100644 --- a/tests/e2e/test_experiments.py +++ b/tests/e2e/test_experiments.py @@ -18,6 +18,7 @@ ExperimentItem, ExperimentItemResult, ) +from tests.support.utils import raw_metadata_value T = TypeVar("T") @@ -216,7 +217,7 @@ def test_run_experiment_on_local_dataset(sample_dataset): assert item.output == expected_output assert item.expected_output == expected_answer assert item.metadata is not None - assert item.metadata["experiment_name"] == "Euro capitals" + assert item.metadata["experiment_name"] == raw_metadata_value("Euro capitals") # Run-level evaluations are persisted for local data, too experiment = get_experiment( @@ -362,9 +363,11 @@ def test_run_experiment_on_langfuse_dataset(): assert item.expected_output == dataset_item.expected_output assert item.metadata is not None - assert item.metadata["experiment_name"] == experiment_name - assert item.metadata["dataset_id"] == dataset.id - assert item.metadata["dataset_item_id"] == item.experiment_item_id + assert item.metadata["experiment_name"] == raw_metadata_value(experiment_name) + assert item.metadata["dataset_id"] == raw_metadata_value(dataset.id) + assert item.metadata["dataset_item_id"] == raw_metadata_value( + item.experiment_item_id + ) assert [s.name for s in item.scores or []] == ["factuality"] diff --git a/tests/support/utils.py b/tests/support/utils.py index 191eeae05..a6b126526 100644 --- a/tests/support/utils.py +++ b/tests/support/utils.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Sequence, TypeVar from uuid import uuid4 +from langfuse._version import __version__ as langfuse_version from langfuse.api import LangfuseAPI, ObservationV2, ScoreV3 from tests.support.retry import ( DEFAULT_RETRY_INTERVAL_SECONDS, @@ -37,6 +38,19 @@ ) _SDK_METADATA_KEY_PREFIXES = ("scope.", "resourceAttributes.") +# The server JSON-decodes per-key observation metadata values only for Python +# SDK major >= 5 (langfuse/langfuse#18436). Older SDK versions read back the raw +# JSON string on endpoints that do not parse metadata values on read. +_SERVER_DECODES_METADATA_VALUES = int(langfuse_version.split(".")[0]) >= 5 + + +def raw_metadata_value(value: Any) -> Any: + """Per-key metadata value as stored by the server for this SDK version.""" + if _SERVER_DECODES_METADATA_VALUES: + return value + + return json.dumps(value, separators=(",", ":"), ensure_ascii=False) + def _has_filters(kwargs: dict[str, Any]) -> bool: return any( diff --git a/tests/unit/test_langchain.py b/tests/unit/test_langchain.py index 0e63fb6b1..dbaffda91 100644 --- a/tests/unit/test_langchain.py +++ b/tests/unit/test_langchain.py @@ -845,7 +845,7 @@ def test_tool_when_structured_inputs_only_store_in_inputs_attribute_not_metadata metadata_prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA - assert span.attributes[f"{metadata_prefix}.custom_key"] == "custom_value" + assert span.attributes[f"{metadata_prefix}.custom_key"] == '"custom_value"' assert f"{metadata_prefix}.inputs" not in span.attributes diff --git a/tests/unit/test_mask_otel_spans.py b/tests/unit/test_mask_otel_spans.py index d9a23befb..d6ad7ec9a 100644 --- a/tests/unit/test_mask_otel_spans.py +++ b/tests/unit/test_mask_otel_spans.py @@ -21,6 +21,11 @@ from opentelemetry.trace import SpanContext, TraceFlags, TraceState import langfuse._client.span_exporter as span_exporter_module +from langfuse._client.attributes import ( + LangfuseOtelSpanAttributes, + _flatten_and_serialize_metadata, + _serialize_metadata_value, +) from langfuse._client.constants import LANGFUSE_TRACER_NAME from langfuse._client.span_processor import LangfuseSpanProcessor from langfuse._task_manager.media_manager import MediaManager @@ -250,6 +255,112 @@ def test_export_stage_media_processes_direct_data_uri_string(): assert not media_queue.empty() +def test_export_stage_media_processes_json_encoded_metadata_data_uri(): + exporter = InMemorySpanExporter() + media_manager, media_queue = _media_manager() + image_base64 = base64.b64encode(b"image-bytes").decode("utf-8") + data_uri = f"data:image/jpeg;base64,{image_base64}" + + provider = _tracer_provider(exporter=exporter, media_manager=media_manager) + tracer = provider.get_tracer("openinference.instrumentation.openai") + + observation_key = f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.image" + trace_key = f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.image" + + with tracer.start_as_current_span("json-metadata-media-span") as span: + span.set_attributes( + { + **_flatten_and_serialize_metadata({"image": data_uri}, "observation"), + trace_key: _serialize_metadata_value(data_uri), + # A quoted data URI outside Langfuse metadata is left as is. + "custom.payload": _serialize_metadata_value(data_uri), + } + ) + + provider.force_flush() + + attributes = exporter.get_finished_spans()[0].attributes + + for key in (observation_key, trace_key): + assert attributes[key].startswith('"@@@langfuseMedia:') + assert json.loads(attributes[key]).startswith("@@@langfuseMedia:") + assert image_base64 not in attributes[key] + assert attributes["custom.payload"] == json.dumps(data_uri) + assert media_queue.qsize() == 2 + + +def test_export_stage_media_extracts_json_encoded_metadata_data_uri_in_place( + monkeypatch, +): + media_manager, media_queue = _media_manager() + image_base64 = base64.b64encode(b"image-bytes").decode("utf-8") + json_load_calls = 0 + original_json_loads = json.loads + + def count_json_loads(*args, **kwargs): + nonlocal json_load_calls + json_load_calls += 1 + return original_json_loads(*args, **kwargs) + + monkeypatch.setattr(span_exporter_module.json, "loads", count_json_loads) + + transforming_exporter = span_exporter_module.LangfuseTransformingSpanExporter( + exporter=InMemorySpanExporter(), + media_manager=media_manager, + mask_otel_spans=None, + ) + span = SimpleNamespace( + name="json-metadata-media-in-place", + context=SimpleNamespace(trace_id=1, span_id=2), + ) + + result = transforming_exporter._process_media_string( + span=span, + attribute_key=f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.image", + value=_serialize_metadata_value(f"data:image/jpeg;base64,{image_base64}"), + ) + + assert json_load_calls == 0 + assert result.startswith('"@@@langfuseMedia:type=image/jpeg|') + assert original_json_loads(result).startswith("@@@langfuseMedia:") + assert media_queue.qsize() == 1 + + +@pytest.mark.parametrize( + "value", + [ + # Valid JSON for a data URI, but with the "/" characters escaped. + '"data:image/jpeg;base64,' + + base64.b64encode(b"\xff\xff\xff").decode().replace("/", "\\/") + + '"', + # Not a valid JSON string: an unescaped quote inside the value. + '"data:image/jpeg;base64,' + base64.b64encode(b"img").decode() + '" "x"', + # The ";base64" header is not within the first 256 characters. + '"data:image/jpeg;' + "p=1;" * 64 + 'base64,aW1n"', + ], +) +def test_export_stage_media_leaves_escaped_json_encoded_metadata_unchanged(value): + media_manager, media_queue = _media_manager() + transforming_exporter = span_exporter_module.LangfuseTransformingSpanExporter( + exporter=InMemorySpanExporter(), + media_manager=media_manager, + mask_otel_spans=None, + ) + span = SimpleNamespace( + name="json-metadata-escaped-media", + context=SimpleNamespace(trace_id=1, span_id=2), + ) + + result = transforming_exporter._process_media_string( + span=span, + attribute_key=f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.image", + value=value, + ) + + assert result == value + assert media_queue.empty() + + def test_export_stage_media_processes_string_sequence_attributes(): exporter = InMemorySpanExporter() media_manager, media_queue = _media_manager() diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index 4e871b17a..edf517d1f 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -130,7 +130,7 @@ def test_update_over_limit_keeps_earlier_attributes( assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" for i in range(100): - assert attributes[f"{METADATA_PREFIX}first_{i}"] == i + assert attributes[f"{METADATA_PREFIX}first_{i}"] == str(i) second = [key for key in _metadata_keys(span) if ".second_" in key] assert second == [f"{METADATA_PREFIX}second_{i}" for i in range(len(second))] @@ -177,7 +177,7 @@ def test_overwriting_existing_metadata_keys_at_limit_is_allowed( assert span.dropped_attributes == 0 assert len(span.attributes) == len(span_at_start.attributes) for key in kept: - assert span.attributes[key] == "new" + assert span.attributes[key] == '"new"' assert _limit_warnings(caplog) == [] @@ -242,8 +242,8 @@ def test_metadata_under_limit_is_unchanged(langfuse_memory_client, get_span, cap assert span.dropped_attributes == 0 assert len(_metadata_keys(span)) == 51 for i in range(50): - assert span.attributes[f"{METADATA_PREFIX}key_{i}"] == i - assert span.attributes[f"{METADATA_PREFIX}extra"] == "value" + assert span.attributes[f"{METADATA_PREFIX}key_{i}"] == str(i) + assert span.attributes[f"{METADATA_PREFIX}extra"] == '"value"' assert _limit_warnings(caplog) == [] @@ -566,10 +566,14 @@ def task(**kwargs): # Observation metadata is trimmed, but the run keys survive and the run # value wins over the user key of the same name. assert 0 < len(_metadata_keys(task_span)) < len(item_metadata) + 4 - assert attributes[f"{METADATA_PREFIX}experiment_name"] == "big-metadata-experiment" - assert attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name - assert attributes[f"{METADATA_PREFIX}dataset_id"] == "dataset-1" - assert attributes[f"{METADATA_PREFIX}dataset_item_id"] == "item-1" + assert attributes[f"{METADATA_PREFIX}experiment_name"] == json.dumps( + "big-metadata-experiment" + ) + assert attributes[f"{METADATA_PREFIX}experiment_run_name"] == json.dumps( + result.run_name + ) + assert attributes[f"{METADATA_PREFIX}dataset_id"] == '"dataset-1"' + assert attributes[f"{METADATA_PREFIX}dataset_item_id"] == '"item-1"' assert f"{METADATA_PREFIX}item_149" not in attributes item_run = get_span("experiment-item-run") @@ -577,8 +581,8 @@ def task(**kwargs): assert item_run.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == ( "the answer" ) - assert ( - item_run.attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name + assert item_run.attributes[f"{METADATA_PREFIX}experiment_run_name"] == json.dumps( + result.run_name ) assert LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA in item_run.attributes diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index cacdae5c2..b688fc6f1 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -289,7 +289,7 @@ def test_chat_completion_exports_generation_span( assert ( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o-mini" ) - assert span.attributes["langfuse.observation.metadata.suite"] == "unit" + assert span.attributes["langfuse.observation.metadata.suite"] == '"unit"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_INPUT) == [ {"role": "user", "content": "1 + 1 = ?"} ] @@ -371,7 +371,7 @@ def test_openai_stream_with_none_choices_chunk_does_not_crash( span = get_span("unit-openai-stream-none-choices") assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "2" - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' def test_streaming_chat_completion_preserves_tool_calls_after_content(): @@ -507,7 +507,7 @@ def fake_stream(): span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -575,7 +575,7 @@ def test_openai_stream_preserves_original_stream_contract( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -610,7 +610,7 @@ def test_openai_stream_handles_trailing_azure_content_filter_chunk( span = get_span("unit-openai-native-stream-azure-filter") assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "2" - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -734,7 +734,7 @@ async def test_openai_async_stream_preserves_original_stream_contract( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -777,7 +777,7 @@ async def test_openai_async_stream_supports_anext( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 286a5e4d0..ac851a95a 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -1,4 +1,5 @@ import json +import logging from datetime import datetime from hashlib import sha256 from typing import List, Sequence @@ -414,7 +415,7 @@ def test_span_attributes(self, langfuse_client, memory_exporter): # Verify attribute values assert input_data == {"prompt": "Test prompt"} assert output_data == {"response": "Updated response"} - assert metadata_data == "test-session" + assert metadata_data == '"test-session"' assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "INFO" assert ( attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] @@ -530,7 +531,7 @@ def test_trace_update(self, langfuse_client, memory_exporter): # Check attribute values assert sorted(tags) == sorted(["tag1", "tag2"]) - assert metadata == "data" + assert metadata == '"data"' def test_complex_scenario(self, langfuse_client, memory_exporter): """Test a more complex scenario with multiple operations and nesting.""" @@ -608,7 +609,7 @@ def test_complex_scenario(self, langfuse_client, memory_exporter): proc_metadata = proc["attributes"][ f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.step" ] - assert proc_metadata == "processing" + assert proc_metadata == '"processing"' # Parse input/output JSON llm_input = json.loads( @@ -1545,17 +1546,15 @@ class TestMetadataHandling(TestOTelBase): def test_complex_metadata_serialization(self): """Test the _flatten_and_serialize_metadata function directly.""" - from langfuse._client.attributes import ( - _flatten_and_serialize_metadata, - _serialize, - ) + from langfuse._client.attributes import _flatten_and_serialize_metadata # Test case 1: Non-dict metadata non_dict_result = _flatten_and_serialize_metadata("string-value", "observation") assert LangfuseOtelSpanAttributes.OBSERVATION_METADATA in non_dict_result - assert non_dict_result[ - LangfuseOtelSpanAttributes.OBSERVATION_METADATA - ] == _serialize("string-value") + assert ( + non_dict_result[LangfuseOtelSpanAttributes.OBSERVATION_METADATA] + == '"string-value"' + ) # Test case 2: Simple dict simple_dict = {"key1": "value1", "key2": 123} @@ -1568,11 +1567,11 @@ def test_complex_metadata_serialization(self): ) assert ( simple_result[f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.key1"] - == "value1" + == '"value1"' ) assert ( simple_result[f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.key2"] - == 123 + == "123" ) # Test case 3: Nested dict (will be flattened in current implementation) @@ -1625,7 +1624,7 @@ def test_complex_metadata_serialization(self): # The nested structures are serialized as JSON strings assert json.loads(complex_result[level1_key]) == complex_dict["level1"] - assert complex_result[sibling_key] == "value" + assert complex_result[sibling_key] == '"value"' def test_nested_metadata_updates(self): """Test that nested metadata updates don't overwrite unrelated keys.""" @@ -1980,7 +1979,206 @@ def update_random_metadata(thread_id): assert "version" in system_data assert "features" in system_data + def test_metadata_values_are_json_encoded(self, langfuse_client, memory_exporter): + """Every per-key metadata value is sent as a JSON-encoded string.""" + from datetime import timezone + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + with langfuse_client.start_as_current_observation( + name="json-metadata-span", + metadata={ + "string": "hello", + "numeric_string": "123", + "bool_string": "true", + "int": 5, + "bool": True, + "float": 1.5, + "big_int": 2**70, + "nan": float("nan"), + "list": [1, "a", None], + "nested": {"a": {"b": [1, 2]}, "c": "d"}, + "datetime": datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + }, + ): + pass + + attributes = self.get_spans_by_name(memory_exporter, "json-metadata-span")[0][ + "attributes" + ] + + expected = { + "string": '"hello"', + "numeric_string": '"123"', + "bool_string": '"true"', + "int": "5", + "bool": "true", + "float": "1.5", + "big_int": '"1180591620717411303424"', + "nan": '"NaN"', + "list": '[1,"a",null]', + "nested": '{"a":{"b":[1,2]},"c":"d"}', + "datetime": '"2024-01-02T03:04:05Z"', + } + for key, value in expected.items(): + assert attributes[f"{prefix}.{key}"] == value, key + + # Decoding restores the original type, so strings and numbers stay distinct. + assert json.loads(attributes[f"{prefix}.numeric_string"]) == "123" + assert json.loads(attributes[f"{prefix}.int"]) == 5 + assert json.loads(attributes[f"{prefix}.bool"]) is True + + def test_big_int_metadata_value_survives_otlp_encoding( + self, langfuse_client, memory_exporter + ): + """Ints beyond int64 must not make the OTLP encoder drop the key.""" + from opentelemetry.exporter.otlp.proto.common.trace_encoder import ( + encode_spans, + ) + + with langfuse_client.start_as_current_observation( + name="big-int-span", metadata={"big": 2**70, "small": 1} + ): + pass + + spans = [ + s for s in memory_exporter.get_finished_spans() if s.name == "big-int-span" + ] + request = encode_spans(spans) + encoded = { + kv.key: kv.value.string_value + for kv in request.resource_spans[0].scope_spans[0].spans[0].attributes + } + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + assert encoded[f"{prefix}.big"] == '"1180591620717411303424"' + assert encoded[f"{prefix}.small"] == "1" + + def test_none_metadata_value_keeps_earlier_value_on_update( + self, langfuse_client, memory_exporter + ): + """None values are skipped, so they don't overwrite earlier keys.""" + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + with langfuse_client.start_as_current_observation( + name="none-metadata-span", metadata={"keep": "first", "other": 1} + ) as span: + span.update(metadata={"keep": None, "other": 2}) + + attributes = self.get_spans_by_name(memory_exporter, "none-metadata-span")[0][ + "attributes" + ] + + assert attributes[f"{prefix}.keep"] == '"first"' + assert attributes[f"{prefix}.other"] == "2" + + def test_none_metadata_value_is_debug_logged(self, caplog): + """Skipped None values are logged at debug level, non-None keys are not.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + caplog.set_level(logging.DEBUG, logger="langfuse") + + _flatten_and_serialize_metadata( + {"keep": None, "other": 2, "text": "x"}, "observation" + ) + + messages = [ + record.getMessage() + for record in caplog.records + if record.name == "langfuse" + ] + assert messages == [ + 'Observation metadata key "keep" was not written because its value is None' + ] + + @pytest.mark.parametrize( + "metadata, expected", + [("foo", '"foo"'), (5, "5"), ([1, "a", None], '[1,"a",null]')], + ) + def test_non_dict_metadata_is_json_encoded( + self, langfuse_client, memory_exporter, metadata, expected + ): + """Non-dict metadata is sent JSON-encoded on the bare key, strings included.""" + with langfuse_client.start_as_current_observation( + name="non-dict-metadata-span", metadata=metadata + ): + pass + + attributes = self.get_spans_by_name(memory_exporter, "non-dict-metadata-span")[ + 0 + ]["attributes"] + + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_METADATA] == expected + + def test_flatten_metadata_trace_prefix_uses_same_encoding(self): + """The trace prefix variant encodes values the same way.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + result = _flatten_and_serialize_metadata( + {"s": "x", "i": 1, "none": None}, "trace" + ) + + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + assert result == {f"{prefix}.s": '"x"', f"{prefix}.i": "1"} + + def test_metadata_json_matches_js_json_stringify(self): + """Values use compact separators and keep non-ASCII, like JSON.stringify.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + result = _flatten_and_serialize_metadata( + {"umlaut": "ü", "nested": {"a": {"b": [1, "x"]}}}, "observation" + ) + + assert result == { + f"{prefix}.umlaut": '"ü"', + f"{prefix}.nested": '{"a":{"b":[1,"x"]}}', + } + + non_dict_result = _flatten_and_serialize_metadata([1, "ü"], "observation") + assert non_dict_result == {prefix: '[1,"ü"]'} + + # Expected values are the output of JSON.stringify in Node. + @pytest.mark.parametrize( + "value, js_json_stringify", + [ + ("prod", '"prod"'), + ("123", '"123"'), + ("", '""'), + ('a"b\\c', '"a\\"b\\\\c"'), + ("line\nbreak\ttab\b\f\r", '"line\\nbreak\\ttab\\b\\f\\r"'), + ("\x00\x1f\x7f", '"\\u0000\\u001f\x7f"'), + ("\u2028\u2029", '"\u2028\u2029"'), + ("ü Läufe 🚀 日本", '"ü Läufe 🚀 日本"'), + ("", '""'), + ], + ) + def test_string_fast_path_matches_event_serializer_and_js( + self, value, js_json_stringify + ): + """Plain strings skip EventSerializer but must encode to the same bytes.""" + from langfuse._client.attributes import _serialize_metadata_value + from langfuse._utils.serializer import EventSerializer + + via_event_serializer = json.dumps( + value, cls=EventSerializer, separators=(",", ":"), ensure_ascii=False + ) + + assert _serialize_metadata_value(value) == via_event_serializer + assert _serialize_metadata_value(value) == js_json_stringify + + def test_str_subclass_metadata_goes_through_event_serializer(self): + """str subclasses keep EventSerializer handling, e.g. str enums.""" + from enum import Enum + + from langfuse._client.attributes import _serialize_metadata_value + + class Stage(str, Enum): + PROD = "prod" + + assert _serialize_metadata_value(Stage.PROD) == '"prod"' + MASK_FALLBACK = "" + # Metadata attributes carry the fallback JSON-encoded like any other value. + ENCODED_MASK_FALLBACK = json.dumps(MASK_FALLBACK) def get_metadata_attributes(self, span_data: dict) -> dict: prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA @@ -2006,9 +2204,9 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail")[0] prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA assert self.get_metadata_attributes(span_data) == { - f"{prefix}.a": 1, - f"{prefix}.secret": self.MASK_FALLBACK, - f"{prefix}.b": self.MASK_FALLBACK, + f"{prefix}.a": "1", + f"{prefix}.secret": self.ENCODED_MASK_FALLBACK, + f"{prefix}.b": self.ENCODED_MASK_FALLBACK, } def test_failed_mask_on_start_masks_each_metadata_key( @@ -2028,8 +2226,8 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-start")[0] prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA assert self.get_metadata_attributes(span_data) == { - f"{prefix}.secret": self.MASK_FALLBACK, - f"{prefix}.b": self.MASK_FALLBACK, + f"{prefix}.secret": self.ENCODED_MASK_FALLBACK, + f"{prefix}.b": self.ENCODED_MASK_FALLBACK, } def test_failed_mask_keeps_string_fallback_for_empty_dict_metadata( @@ -2048,7 +2246,7 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-empty-dict")[0] assert self.get_metadata_attributes(span_data) == { - LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.ENCODED_MASK_FALLBACK, } def test_failed_mask_keeps_string_fallback_for_non_dict_values( @@ -2071,7 +2269,7 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-non-dict")[0] attributes = span_data["attributes"] assert self.get_metadata_attributes(span_data) == { - LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.ENCODED_MASK_FALLBACK, } assert ( attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 024bac64f..89d8fb3c2 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -156,24 +156,24 @@ def test_metadata_propagates_to_child_spans(self, langfuse_client, memory_export self.verify_span_attribute( child1_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "variant_a", + '"variant_a"', ) self.verify_span_attribute( child1_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", + '"1.0"', ) child2_span = self.get_span_by_name(memory_exporter, "child-span-2") self.verify_span_attribute( child2_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "variant_a", + '"variant_a"', ) self.verify_span_attribute( child2_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", + '"1.0"', ) def test_all_attributes_propagate_together(self, langfuse_client, memory_exporter): @@ -198,12 +198,12 @@ def test_all_attributes_propagate_together(self, langfuse_client, memory_exporte self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "test", + '"test"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) @@ -485,7 +485,6 @@ def test_non_string_metadata_values_json_serialized( "ratio": 0.5, "config": {"model": "gpt-4o", "nested": {"b": [1, None], "a": "ü"}}, "label": ["Läufe", "🚀"], - "empty": None, } # Byte-identical to JSON.stringify in the JS SDK for the same values. expected = { @@ -497,7 +496,6 @@ def test_non_string_metadata_values_json_serialized( "ratio": "0.5", "config": '{"model":"gpt-4o","nested":{"b":[1,null],"a":"ü"}}', "label": '["Läufe","🚀"]', - "empty": "null", } with langfuse_client.start_as_current_observation(name="parent-span"): @@ -516,10 +514,37 @@ def test_non_string_metadata_values_json_serialized( assert "value is not a string. Dropping value." not in caplog.text - def test_large_integer_metadata_keeps_its_digits( + def test_none_metadata_value_is_dropped_with_warning( + self, langfuse_client, memory_exporter, caplog + ): + """Verify None values are dropped and don't overwrite an outer value.""" + caplog.set_level("WARNING", logger="langfuse") + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"outer": "kept"}): + with propagate_attributes( + metadata={"outer": None, "empty": None, "other": "x"} + ): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute(child_span, f"{prefix}.outer", '"kept"') + self.verify_span_attribute(child_span, f"{prefix}.other", '"x"') + self.verify_missing_attribute(child_span, f"{prefix}.empty") + assert ( + "Propagated attribute 'metadata.empty' is None. Dropping value." + in caplog.text + ) + assert ( + "Propagated attribute 'metadata.outer' is None. Dropping value." + in caplog.text + ) + + def test_large_integer_metadata_becomes_json_string( self, langfuse_client, memory_exporter ): - """Verify integers beyond JS's safe range are sent as plain digits.""" + """Verify integers beyond JS's safe range become JSON strings, like observation metadata.""" with langfuse_client.start_as_current_observation(name="parent-span"): with propagate_attributes(metadata={"snowflake_id": 9007199254740993}): child = langfuse_client.start_observation(name="child-span") @@ -529,13 +554,13 @@ def test_large_integer_metadata_keeps_its_digits( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.snowflake_id", - "9007199254740993", + '"9007199254740993"', ) - def test_nested_large_integer_metadata_keeps_its_digits( + def test_nested_large_integer_metadata_becomes_json_string( self, langfuse_client, memory_exporter ): - """Verify nested integers beyond JS's safe range stay unquoted digits.""" + """Verify nested integers beyond JS's safe range become JSON strings.""" with langfuse_client.start_as_current_observation(name="parent-span"): with propagate_attributes( metadata={ @@ -550,19 +575,18 @@ def test_nested_large_integer_metadata_keeps_its_digits( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ids", - "[9007199254740993,1]", + '["9007199254740993",1]', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ref", - '{"snowflake_id":9007199254740993}', + '{"snowflake_id":"9007199254740993"}', ) - def test_non_finite_number_metadata_is_dropped( - self, langfuse_client, memory_exporter, caplog + def test_non_finite_number_metadata_becomes_json_string( + self, langfuse_client, memory_exporter ): - """Verify values containing NaN or Infinity are dropped, like in the JS SDK.""" - caplog.set_level("WARNING", logger="langfuse") + """Verify NaN and Infinity become JSON strings, like observation metadata.""" with langfuse_client.start_as_current_observation(name="parent-span"): with propagate_attributes( metadata={ @@ -576,14 +600,74 @@ def test_non_finite_number_metadata_is_dropped( child.end() child_span = self.get_span_by_name(memory_exporter, "child-span") + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + self.verify_span_attribute(child_span, f"{prefix}.kept", "1.5") + self.verify_span_attribute(child_span, f"{prefix}.nan", '"NaN"') + self.verify_span_attribute(child_span, f"{prefix}.inf", '"-Infinity"') self.verify_span_attribute( - child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.kept", "1.5" + child_span, f"{prefix}.nested", '{"scores":[1.0,"Infinity"]}' ) - for key in ("nan", "inf", "nested"): - self.verify_missing_attribute( - child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + + def test_string_metadata_is_json_encoded_like_observation_metadata( + self, langfuse_client, memory_exporter + ): + """Verify strings are JSON-encoded so "123" stays a string on the server.""" + metadata = { + "plain": "prod", + "numeric_string": "123", + "bool_string": "true", + "null_string": "null", + "json_string": '{"a":1}', + "umlaut": "ü", + } + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata=metadata): + child = langfuse_client.start_observation( + name="child-span", metadata=metadata + ) + child.end() + + attributes = self.get_span_by_name(memory_exporter, "child-span")["attributes"] + for key, value in metadata.items(): + trace_value = attributes[ + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + ] + assert ( + trace_value + == attributes[ + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.{key}" + ] ) - assert "metadata.nan" in caplog.text + assert json.loads(trace_value) == value + + assert ( + attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.numeric_string"] + == '"123"' + ) + assert ( + attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.umlaut"] == '"ü"' + ) + + def test_metadata_length_limit_applies_after_json_encoding( + self, langfuse_client, memory_exporter + ): + """Verify the 200 character limit counts the quotes added by JSON encoding.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={"fits": "a" * 198, "too_long": "b" * 199} + ): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.fits", + '"' + "a" * 198 + '"', + ) + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.too_long" + ) def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): """Verify mixed valid/invalid metadata - valid entries kept, invalid dropped.""" @@ -603,12 +687,12 @@ def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.valid_key", - "valid_value", + '"valid_value"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.another_valid", - "ok", + '"ok"', ) self.verify_missing_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.invalid_key" @@ -717,12 +801,12 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_missing_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" @@ -733,22 +817,22 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "A", + '"A"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "2.0", + '"2.0"', ) # Verify: after span has only outer metadata (inner context exited) @@ -756,12 +840,12 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_missing_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" @@ -789,26 +873,26 @@ def test_nested_metadata_inner_overwrites_conflicting_keys( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", # Inner value wins + '"production"', # Inner value wins ) # Preserved keys from outer self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", # From outer + '"1.0"', # From outer ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-west", # From outer + '"us-west"', # From outer ) # New key from inner self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "B", # From inner + '"B"', # From inner ) def test_triple_nested_metadata_accumulates(self, langfuse_client, memory_exporter): @@ -827,24 +911,24 @@ def test_triple_nested_metadata_accumulates(self, langfuse_client, memory_export self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", - "3", + '"3"', ) # Unique keys from each level self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.a", - "outer", + '"outer"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.b", - "middle", + '"middle"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.c", - "inner", + '"inner"', ) def test_metadata_merge_with_empty_inner(self, langfuse_client, memory_exporter): @@ -861,12 +945,12 @@ def test_metadata_merge_with_empty_inner(self, langfuse_client, memory_exporter) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key1", - "value1", + '"value1"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key2", - "value2", + '"value2"', ) def test_metadata_merge_preserves_user_session( @@ -894,12 +978,12 @@ def test_metadata_merge_preserves_user_session( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.outer", - "value", + '"value"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.inner", - "value", + '"value"', ) @@ -1050,9 +1134,9 @@ def test_multiple_metadata_keys_independent(self, langfuse_client, memory_export attributes = child_span["attributes"] # Verify all three are separate attributes with correct values - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k1"] == "v1" - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k2"] == "v2" - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k3"] == "v3" + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k1"] == '"v1"' + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k2"] == '"v2"' + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k3"] == '"v3"' class TestPropagateAttributesThreading(TestPropagateAttributesBase): @@ -1361,7 +1445,7 @@ def test_nested_spans_from_multiple_tracers( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "cross_tracer", + '"cross_tracer"', ) def test_other_tracer_span_before_propagate_context( @@ -1421,17 +1505,17 @@ def test_mixed_tracers_with_metadata( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "2.0", + '"2.0"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.feature_flag", - "enabled", + '"enabled"', ) def test_propagate_without_langfuse_parent( @@ -1555,7 +1639,7 @@ async def level_1(): self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", - "nested", + '"nested"', ) @pytest.mark.asyncio @@ -1685,12 +1769,12 @@ async def async_with_metadata(): self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.async", - "true", + '"true"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.operation", - "test", + '"test"', ) @@ -1721,10 +1805,10 @@ def test_baggage_is_set_when_as_baggage_true(self, langfuse_client): assert baggage_entries["langfuse_session_id"] == "session_abc" assert "langfuse_metadata_env" in baggage_entries - assert baggage_entries["langfuse_metadata_env"] == "test" + assert baggage_entries["langfuse_metadata_env"] == '"test"' assert "langfuse_metadata_version" in baggage_entries - assert baggage_entries["langfuse_metadata_version"] == "2.0" + assert baggage_entries["langfuse_metadata_version"] == '"2.0"' def test_spans_receive_attributes_from_baggage( self, langfuse_client, memory_exporter @@ -1754,7 +1838,7 @@ def test_spans_receive_attributes_from_baggage( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", - "baggage", + '"baggage"', ) def test_baggage_disabled_by_default(self, langfuse_client): @@ -1803,12 +1887,12 @@ def test_metadata_key_with_user_id_substring_doesnt_collide( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_info", - "some_data", + '"some_data"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_id_copy", - "another", + '"another"', ) def test_metadata_key_with_session_substring_doesnt_collide( @@ -1834,12 +1918,12 @@ def test_metadata_key_with_session_substring_doesnt_collide( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_data", - "value1", + '"value1"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_id_backup", - "value2", + '"value2"', ) def test_metadata_keys_extract_correctly_from_baggage( @@ -1864,17 +1948,17 @@ def test_metadata_keys_extract_correctly_from_baggage( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-west", + '"us-west"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment_id", - "exp_123", + '"exp_123"', ) def test_baggage_and_context_both_propagate(self, langfuse_client, memory_exporter): @@ -1904,7 +1988,7 @@ def test_baggage_and_context_both_propagate(self, langfuse_client, memory_export self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", - "both", + '"both"', ) def test_baggage_survives_context_isolation(self, langfuse_client, memory_exporter): @@ -2214,12 +2298,12 @@ def test_version_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) def test_version_validation_over_200_chars(self, langfuse_client, memory_exporter): @@ -2493,7 +2577,7 @@ def test_tags_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "staging", + '"staging"', ) def test_tags_validation_with_invalid_tag(self, langfuse_client, memory_exporter): @@ -2857,17 +2941,17 @@ def test_experiment_run_metadata_overrides_item_metadata( self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.shared", - "run", + '"run"', ) self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.experiment_run_name", - "run-name", + '"run-name"', ) self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.item_only", - "yes", + '"yes"', ) def test_experiment_attributes_propagate_with_dataset( @@ -3352,12 +3436,12 @@ def test_trace_name_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) def test_trace_name_validation_over_200_chars( From 3b41467fa7924926d8825942f1f3e63850b09d19 Mon Sep 17 00:00:00 2001 From: Neumanncheng <93020150+Neumanncheng@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:04:58 +0800 Subject: [PATCH 19/31] fix(tracing): do not mask attributes the caller never set (#1973) * fix(tracing): do not mask attributes the caller never set `_process_media_and_apply_mask` runs for input, output and metadata on every span creation and update, regardless of whether the caller provided them. An unset attribute is still handed to the user's mask function as `None`. A mask that assumes a dict throws, `_mask_attribute` then fails closed, and the fallback string is not `None` -- so it survives the `v is not None` filters in `create_span_attributes` / `create_generation_attributes`, and lands on the span as an attribute the caller never set. Each unset attribute also logs one ERROR per span, which makes it look like the user's mask function is broken. Repro with a mask that assumes a dict and only `output` set: mask receives [None, None, None, None, {'answer': '42'}, None] span carries langfuse.observation.input / .metadata = '' plus 5 ERROR logs ("'NoneType' object is not a mapping") Return early when `data is None`: there is nothing to process or mask. Attributes that do have a value keep the existing fail-closed behaviour and the per-key metadata fallback. Adds two regression tests asserting that only attributes the caller set reach the mask, and that unset attributes stay unset. * push --------- Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> --- langfuse/_client/span.py | 3 +++ tests/unit/test_otel.py | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 21bb9fabb..54ce58308 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -706,6 +706,9 @@ def _process_media_and_apply_mask( Returns: The processed and masked data """ + if data is None: + return None + return self._mask_attribute( data=self._process_media_in_attribute(data=data, field=field), field=field ) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index ac851a95a..1e2d12820 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -2280,6 +2280,63 @@ def mask(*, data, **kwargs): == self.MASK_FALLBACK ) + def test_unset_attributes_are_never_passed_to_the_mask( + self, configurable_langfuse_client, memory_exporter + ): + """Only attributes the caller set may reach the mask function. + + A mask that assumes it always receives a dict (the common shape) must not + trigger the fallback for attributes the caller never provided. Otherwise + phantom `input`/`metadata` attributes get written to the span. + """ + seen = [] + + def mask(*, data, **kwargs): + seen.append(data) + return {**data, "email": "***"} + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation(name="mask-unset") + span.update(output={"answer": "42"}) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-unset")[0] + attributes = span_data["attributes"] + + assert seen == [{"answer": "42"}] + assert json.loads( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] + ) == {"answer": "42", "email": "***"} + assert LangfuseOtelSpanAttributes.OBSERVATION_INPUT not in attributes + assert self.get_metadata_attributes(span_data) == {} + + def test_unset_attributes_stay_unset_when_created_with_metadata_only( + self, configurable_langfuse_client, memory_exporter + ): + """Creating a span with only metadata must not mask a missing input/output.""" + seen = [] + + def mask(*, data, **kwargs): + seen.append(data) + return {**data, "email": "***"} + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation( + name="mask-unset-start", metadata={"k": 1} + ) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-unset-start")[0] + attributes = span_data["attributes"] + + assert seen == [{"k": 1}] + assert self.get_metadata_attributes(span_data) == { + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.k": 1, + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.email": "***", + } + assert LangfuseOtelSpanAttributes.OBSERVATION_INPUT not in attributes + assert LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT not in attributes + class TestMultiProjectSetup(TestOTelBase): """Tests for multi-project setup within the same process. From 4c845f8d286c7bc6505f60d82fe9a858ac880f88 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:08:05 +0400 Subject: [PATCH 20/31] test(tracing): expect JSON-encoded metadata in mask-unset test (#1985) The test asserted raw metadata values, but metadata values are JSON-encoded on prepare-v5-release, so it failed there. Co-authored-by: Cursor Agent Co-authored-by: Hassieb Pakzad --- tests/unit/test_otel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 1e2d12820..dfe1336fc 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -2331,8 +2331,8 @@ def mask(*, data, **kwargs): assert seen == [{"k": 1}] assert self.get_metadata_attributes(span_data) == { - f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.k": 1, - f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.email": "***", + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.k": "1", + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.email": '"***"', } assert LangfuseOtelSpanAttributes.OBSERVATION_INPUT not in attributes assert LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT not in attributes From 82adbd7144d343f1596cbb2fb63cecd10de5469e Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:44:36 +0400 Subject: [PATCH 21/31] chore: merge main into prepare-v5-release (#1986) * docs(api): document metadata is set / is not set filter operators (#1946) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17357 and #18404. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * fix(api): type Dataset JSON-schema response fields as objects (#1949) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17892 and the datasets.yml/opentelemetry.yml part of #18406. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * refactor(api): share cursor pagination meta via CursorMetaResponse (#1948) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17888. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * docs(api): document fromStartTime as required on experiments endpoints (#1947) * docs(api): document fromStartTime as required on experiments endpoints Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17603. Split out of the bundled API-spec bot PR #1943. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(api): use language-neutral wording for experiments time window Regenerated from langfuse/langfuse branch docs/experiments-from-start-time-neutral-wording. The fromStartTime docstrings named `toStartTime`, but the Python parameter is `to_start_time`; they now say "an upper bound" instead. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix(api): type read-side metadata as optional objects (#1950) * fix(api): type read-side metadata as optional objects Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#18122 and the commons.yml part of #18406. Split out of the bundled API-spec bot PR #1943. Co-Authored-By: Claude Opus 5.5 (1M context) * test(api): pin read-side metadata parsing on generated models Cover omitted, null, empty and nested-JSON metadata plus rejection of non-object metadata for Trace, Observation, ObservationV2, BaseScore, BaseScoreV1 and every Score / ScoreV1 / GetScoresResponseData variant. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Cursor Agent --- tests/unit/test_api_metadata_parsing.py | 114 ++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/test_api_metadata_parsing.py diff --git a/tests/unit/test_api_metadata_parsing.py b/tests/unit/test_api_metadata_parsing.py new file mode 100644 index 000000000..2c8fe8eb9 --- /dev/null +++ b/tests/unit/test_api_metadata_parsing.py @@ -0,0 +1,114 @@ +"""Response parsing of read-side ``metadata`` on generated API models. + +The server returns read-side metadata as a JSON object, ``null``, or omits it. +These tests pin that contract so a future regeneration that makes the field +required or narrows its values is caught. +""" + +import typing + +import pydantic +import pytest + +from langfuse.api import BaseScore, ObservationV2, Score +from langfuse.api.core import parse_obj_as + +TIMESTAMP = "2026-01-01T00:00:00.000Z" + +SCORE_BASE: typing.Dict[str, typing.Any] = { + "id": "score-1", + "name": "quality", + "source": "API", + "timestamp": TIMESTAMP, + "createdAt": TIMESTAMP, + "updatedAt": TIMESTAMP, + "environment": "default", +} + +SCORE_VARIANTS: typing.Dict[str, typing.Dict[str, typing.Any]] = { + "NUMERIC": {"value": 0.5}, + "CATEGORICAL": {"value": 1, "stringValue": "good"}, + "BOOLEAN": {"value": 1, "stringValue": "True"}, + "CORRECTION": {"value": 0, "stringValue": "corrected output"}, + "TEXT": {"stringValue": "free text"}, +} + + +def _score_payload( + base: typing.Dict[str, typing.Any], data_type: str +) -> typing.Dict[str, typing.Any]: + return {**base, "dataType": data_type, **SCORE_VARIANTS[data_type]} + + +CASES: typing.List[typing.Any] = [ + pytest.param( + ObservationV2, + { + "id": "obs-1", + "startTime": TIMESTAMP, + "projectId": "project-1", + "type": "SPAN", + }, + id="ObservationV2", + ), + pytest.param(BaseScore, SCORE_BASE, id="BaseScore"), + *[ + pytest.param(Score, _score_payload(SCORE_BASE, dt), id=f"Score-{dt}") + for dt in SCORE_VARIANTS + ], +] + +NESTED_METADATA = { + "str": "value", + "int": 1, + "float": 1.5, + "bool": True, + "null": None, + "list": [1, "two", {"three": 3}], + "nested": {"deeper": {"key": ["a", "b"]}}, +} + + +@pytest.mark.parametrize(("type_", "payload"), CASES) +def test_metadata_omitted_parses_as_none(type_, payload): + assert "metadata" not in payload + + parsed = parse_obj_as(type_, payload) + + assert parsed.metadata is None + + +@pytest.mark.parametrize(("type_", "payload"), CASES) +def test_metadata_null_parses_as_none(type_, payload): + parsed = parse_obj_as(type_, {**payload, "metadata": None}) + + assert parsed.metadata is None + + +@pytest.mark.parametrize(("type_", "payload"), CASES) +def test_metadata_empty_object_parses(type_, payload): + parsed = parse_obj_as(type_, {**payload, "metadata": {}}) + + assert parsed.metadata == {} + + +@pytest.mark.parametrize(("type_", "payload"), CASES) +def test_metadata_object_with_nested_json_values_parses(type_, payload): + parsed = parse_obj_as(type_, {**payload, "metadata": NESTED_METADATA}) + + assert parsed.metadata == NESTED_METADATA + + +@pytest.mark.parametrize(("type_", "payload"), CASES) +@pytest.mark.parametrize( + "metadata", + ["a string", 1, 1.5, True, ["a", "list"]], + ids=["str", "int", "float", "bool", "list"], +) +def test_metadata_non_object_is_rejected(type_, payload, metadata): + with pytest.raises(pydantic.ValidationError) as exc_info: + parse_obj_as(type_, {**payload, "metadata": metadata}) + + # The discriminated unions prefix the location with the variant tag. + assert exc_info.value.errors() + assert all("metadata" in error["loc"] for error in exc_info.value.errors()) From dac99935c3a039a3b1252189acd9e4b11b957d5f Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:49:07 +0400 Subject: [PATCH 22/31] ci: test on Python 3.15 and declare it supported (#1988) * test: run unit tests without langchain and langgraph installed Co-authored-by: hassieb.pakzad * build: lock dependencies with Python 3.15 wheels Co-authored-by: hassieb.pakzad * ci: run unit tests on Python 3.15 and declare supported versions Co-authored-by: hassieb.pakzad --------- Co-authored-by: Cursor Agent --- .github/workflows/ci.yml | 7 + AGENTS.md | 2 +- pyproject.toml | 18 +- tests/unit/test_json.py | 2 +- tests/unit/test_langchain.py | 4 +- uv.lock | 1766 ++++++++++++++++++++-------------- 6 files changed, 1046 insertions(+), 753 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be8c9be1e..ba53ceefb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,12 +74,19 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" name: Unit tests on Python ${{ matrix.python-version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # The pinned uv only knows Python 3.15 pre-releases; install the final release for uv to pick up. + - name: Set up Python 3.15 + if: ${{ matrix.python-version == '3.15' }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.15" - name: Install uv and set Python version uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: diff --git a/AGENTS.md b/AGENTS.md index 851b4e8d1..950bf3055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Minimum verification matrix: The main CI workflow currently runs: - linting on Python 3.13 - mypy on Python 3.13 -- `tests/unit` on a Python 3.11-3.14 matrix +- `tests/unit` on a Python 3.11-3.15 matrix - `tests/e2e` in 2 mechanical shards plus a serial subset inside each shard - `tests/live_provider` as one always-on suite - PR title validation for Conventional Commits diff --git a/pyproject.toml b/pyproject.toml index 00df80e96..4bf991006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,14 @@ authors = [{ name = "langfuse", email = "developers@langfuse.com" }] license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.11,<4.0" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", +] keywords = [ "langfuse", "llm", @@ -55,8 +63,9 @@ dev = [ "openai>=1.0.0", "langchain-core>=1,<2", "langchain-openai>=1,<2", - "langchain>=1,<2", - "langgraph>=1,<2", + # langgraph-checkpoint requires ormsgpack, which cannot be built for Python 3.15 yet + "langchain>=1,<2; python_version < '3.15'", + "langgraph>=1,<2; python_version < '3.15'", "autoevals>=0.0.130,<0.1", "opentelemetry-instrumentation-threading>=0.59b0,<1", "tenacity>=9.1.4", @@ -72,8 +81,9 @@ build-backend = "uv_build" [tool.uv] # Basic protection against supply chain attacks exclude-newer = "7 days" -# OpenTelemetry 1.45 ships the OTLP request size cap the SDK relies on -exclude-newer-package = { "opentelemetry-api" = "3 days", "opentelemetry-sdk" = "3 days", "opentelemetry-semantic-conventions" = "3 days", "opentelemetry-proto" = "3 days", "opentelemetry-exporter-otlp-proto-common" = "3 days", "opentelemetry-exporter-otlp-proto-http" = "3 days", "opentelemetry-exporter-otlp-common" = "3 days", "opentelemetry-exporter-http-transport" = "3 days", "opentelemetry-instrumentation" = "3 days", "opentelemetry-instrumentation-threading" = "3 days" } +# OpenTelemetry 1.45 ships the OTLP request size cap the SDK relies on; +# pydantic 2.14 is the first release with Python 3.15 wheels +exclude-newer-package = { "pydantic" = "3 days", "pydantic-core" = "3 days", "opentelemetry-api" = "3 days", "opentelemetry-sdk" = "3 days", "opentelemetry-semantic-conventions" = "3 days", "opentelemetry-proto" = "3 days", "opentelemetry-exporter-otlp-proto-common" = "3 days", "opentelemetry-exporter-otlp-proto-http" = "3 days", "opentelemetry-exporter-otlp-common" = "3 days", "opentelemetry-exporter-http-transport" = "3 days", "opentelemetry-instrumentation" = "3 days", "opentelemetry-instrumentation-threading" = "3 days" } [tool.uv.build-backend] module-root = "" diff --git a/tests/unit/test_json.py b/tests/unit/test_json.py index 5f46f16ae..e818f54a5 100644 --- a/tests/unit/test_json.py +++ b/tests/unit/test_json.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from langchain.messages import HumanMessage +from langchain_core.messages import HumanMessage from pydantic import BaseModel import langfuse._utils.serializer as serializer_module diff --git a/tests/unit/test_langchain.py b/tests/unit/test_langchain.py index dbaffda91..12639f0bb 100644 --- a/tests/unit/test_langchain.py +++ b/tests/unit/test_langchain.py @@ -4,8 +4,7 @@ from uuid import uuid4 import pytest -from langchain.messages import HumanMessage -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.outputs import ChatGeneration, ChatResult, Generation, LLMResult from langchain_core.prompts import ChatPromptTemplate @@ -924,6 +923,7 @@ class DummyControlFlowError(RuntimeError): def test_graphbubbleup_import_is_independent_from_command_import(): + pytest.importorskip("langgraph.errors") real_import = __import__ def import_without_langgraph_command( diff --git a/uv.lock b/uv.lock index 8e1fd8ec5..850d2ddeb 100644 --- a/uv.lock +++ b/uv.lock @@ -3,20 +3,22 @@ revision = 3 requires-python = ">=3.11, <4.0" [options] -exclude-newer = "2026-09-30T09:24:24.1987207Z" +exclude-newer = "2026-10-06T09:31:30.784708305Z" exclude-newer-span = "P7D" [options.exclude-newer-package] -opentelemetry-sdk = { timestamp = "2026-10-04T09:24:24.198747432Z", span = "P3D" } -opentelemetry-exporter-otlp-proto-common = { timestamp = "2026-10-04T09:24:24.198748763Z", span = "P3D" } -opentelemetry-api = { timestamp = "2026-10-04T09:24:24.198731079Z", span = "P3D" } -opentelemetry-instrumentation-threading = { timestamp = "2026-10-04T09:24:24.198751113Z", span = "P3D" } -opentelemetry-exporter-otlp-proto-http = { timestamp = "2026-10-04T09:24:24.198749247Z", span = "P3D" } -opentelemetry-proto = { timestamp = "2026-10-04T09:24:24.198748273Z", span = "P3D" } -opentelemetry-exporter-http-transport = { timestamp = "2026-10-04T09:24:24.198750056Z", span = "P3D" } -opentelemetry-semantic-conventions = { timestamp = "2026-10-04T09:24:24.198747912Z", span = "P3D" } -opentelemetry-exporter-otlp-common = { timestamp = "2026-10-04T09:24:24.198749653Z", span = "P3D" } -opentelemetry-instrumentation = { timestamp = "2026-10-04T09:24:24.19875072Z", span = "P3D" } +opentelemetry-sdk = { timestamp = "2026-10-10T09:31:30.784962394Z", span = "P3D" } +opentelemetry-exporter-otlp-proto-common = { timestamp = "2026-10-10T09:31:30.784963814Z", span = "P3D" } +opentelemetry-api = { timestamp = "2026-10-10T09:31:30.784962048Z", span = "P3D" } +opentelemetry-instrumentation-threading = { timestamp = "2026-10-10T09:31:30.784965743Z", span = "P3D" } +opentelemetry-proto = { timestamp = "2026-10-10T09:31:30.784963434Z", span = "P3D" } +opentelemetry-exporter-otlp-proto-http = { timestamp = "2026-10-10T09:31:30.784964177Z", span = "P3D" } +opentelemetry-exporter-http-transport = { timestamp = "2026-10-10T09:31:30.784965016Z", span = "P3D" } +opentelemetry-semantic-conventions = { timestamp = "2026-10-10T09:31:30.784963106Z", span = "P3D" } +pydantic-core = { timestamp = "2026-10-10T09:31:30.784961598Z", span = "P3D" } +pydantic = { timestamp = "2026-10-10T09:31:30.784796083Z", span = "P3D" } +opentelemetry-exporter-otlp-common = { timestamp = "2026-10-10T09:31:30.784964659Z", span = "P3D" } +opentelemetry-instrumentation = { timestamp = "2026-10-10T09:31:30.784965353Z", span = "P3D" } [[package]] name = "annotated-types" @@ -324,87 +326,115 @@ wheels = [ [[package]] name = "jiter" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, - { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, - { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, - { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, - { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, - { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, - { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, - { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, - { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, - { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, - { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, - { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, - { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, - { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, - { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, ] [[package]] @@ -521,10 +551,10 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "autoevals" }, - { name = "langchain" }, + { name = "langchain", marker = "python_full_version < '3.15'" }, { name = "langchain-core" }, { name = "langchain-openai" }, - { name = "langgraph" }, + { name = "langgraph", marker = "python_full_version < '3.15'" }, { name = "mypy" }, { name = "openai" }, { name = "opentelemetry-instrumentation-threading" }, @@ -557,10 +587,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "autoevals", specifier = ">=0.0.130,<0.1" }, - { name = "langchain", specifier = ">=1,<2" }, + { name = "langchain", marker = "python_full_version < '3.15'", specifier = ">=1,<2" }, { name = "langchain-core", specifier = ">=1,<2" }, { name = "langchain-openai", specifier = ">=1,<2" }, - { name = "langgraph", specifier = ">=1,<2" }, + { name = "langgraph", marker = "python_full_version < '3.15'", specifier = ">=1,<2" }, { name = "mypy", specifier = ">=1.0.0,<2" }, { name = "openai", specifier = ">=1.0.0" }, { name = "opentelemetry-instrumentation-threading", specifier = ">=0.59b0,<1" }, @@ -653,149 +683,246 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] [[package]] name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] [[package]] @@ -1016,70 +1143,65 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, - { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, - { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, - { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, - { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, - { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, - { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, - { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, - { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, - { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, - { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, - { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, - { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, - { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, - { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, - { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, - { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, - { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, ] [[package]] @@ -1266,7 +1388,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1274,106 +1396,142 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/99/114c2e71405da7610c884c64f8370f85b2fbb69bd427158a3f714515a803/pydantic_core-2.50.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:55684bc850059fc85ff8dc21a3e342b722d3178993c49ebdfdee8b698a432720", size = 2152583, upload-time = "2026-10-08T14:26:12.913Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/fabcfea81a5c3525507af4347fa3a50e51036d5db6611a63e47379776e56/pydantic_core-2.50.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3ae59461625518449f800acc4f874753ae96fcf993c47a27be07beb651371fd", size = 2045652, upload-time = "2026-10-08T14:26:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/226019008a041da423dc59d34f980b2b6e60a71971d07a8f52e10fc4747a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7080928078ff56c07da393392f772054e93a39c8033dc9c04547bd949f9b614", size = 2050325, upload-time = "2026-10-08T14:26:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/b9/29/11c98cd9c49338f64aeb3d6e166c99b65d8949f00f285c5b66de75ade316/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b1bae96f41806b14dd8bb1568d39b5e33b5af7fb27f7be36c78b8fa84708917", size = 2189027, upload-time = "2026-10-08T14:26:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b7/af7d9c4de715cc13e947d1135b73f0c181bce2f01e9f1d9e3c428f9ee31c/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8facaf0a16121ac82cc403a71399a061b74624c1ae2034eec9ed6169c5d16ecb", size = 2388815, upload-time = "2026-10-08T14:26:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/cd95dcd4d066180b2e22eff3dca01ac3af56607f9ecc43b7ba0748496f47/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:610e9f483ac53c5cc59c2d3687224029b54eb494feec2d40a2bcfdd187635192", size = 2431821, upload-time = "2026-10-08T14:26:20.958Z" }, + { url = "https://files.pythonhosted.org/packages/f0/14/7122b78e9915e7dd50f9dcec6041a4e32874907b41b9e0880db2f1c7ae1a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac223ea031905319a64d0df116979ae438b42d6265862a3d63c9f9808c2905a7", size = 2157622, upload-time = "2026-10-08T14:26:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/97/86/fcddead5bda54fc8efa0d80dbba5962e9a91193570e227404c7593707833/pydantic_core-2.50.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:75b451ec5e64a1d4b803317f34710131742ff2e42f4bf5403f597d0af857d7d8", size = 2254921, upload-time = "2026-10-08T14:26:24.607Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e8/041d1c3e3e1f203a656ce592f8c1548e1ef2b0939d19c5ce66432c30e4bd/pydantic_core-2.50.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7eee44c3f1f8acc220a743a5ed5588949a4f18b33df4cfbef2b1277470285401", size = 2268163, upload-time = "2026-10-08T14:26:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/f515994ef1351e883276e85af0a50d839ead9d0d22743fa2c6dfd552f7ce/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f44107b5fdfecc03438feb165431652c8006650471326e53dd86d4c19124f5c5", size = 2302005, upload-time = "2026-10-08T14:26:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/b970c4c87d92c4c974a5c5dc16bcf747f89b4e5729c5c45131267ef0421b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:718d05d4e078c7828f40ad3e310870fe4b837d93a32e7e8497a080c1f1040490", size = 2465387, upload-time = "2026-10-08T14:26:29.529Z" }, + { url = "https://files.pythonhosted.org/packages/4a/84/c0a92f97f9de7f133dfe694d0e8ca033a1c90fdf692dc69dfee3bf8a2e7b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:031867348c98ab49c6d3a16ad39738369ea48da59900d76d71c1e64de71fe79b", size = 2478673, upload-time = "2026-10-08T14:26:31.898Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/88e851761351cfef90382f175b07cede48edafc62a1509575dbc879934ef/pydantic_core-2.50.0-cp311-cp311-win32.whl", hash = "sha256:e41f9d1d9240e8e0d8a670ad3e66c0c00f0b1f7150a31bc6c445a4f87c1cb3ba", size = 2121424, upload-time = "2026-10-08T14:26:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/89184f13b9ebaf6d8710cd0d1cfb466d7dc829de23468ab471b543dd07eb/pydantic_core-2.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:90874428bc6678b26434336c77931c9734ecebedf50132c179205e5a9908761d", size = 2176278, upload-time = "2026-10-08T14:26:35.388Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7a/5a630fff51218a860aa4e7699f781a7c9bf41c031987b613629c98f16618/pydantic_core-2.50.0-cp311-cp311-win_arm64.whl", hash = "sha256:b9be297ffe1015bfb2db4a23b6e1fec7e48361cf7c7b4f4f6bbdd008451b0a7b", size = 2183464, upload-time = "2026-10-08T14:26:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/11/ff/8d126ca04417cbc76a81648c135296ecc2f36d07adfb0b3376638efd1dd0/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:38b03c5439c6a7a952f56e3e6596dae44bc78c3fb6a69df46cabfb2f888e5f0c", size = 2162541, upload-time = "2026-10-08T14:30:12.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/e3f11c40950d0f37b3891c1ed015f04a4ef36c881d04ef1a5d1ac91d1c6f/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:4faa766450ef44d9eabed5b65a280f63f903d1e1ed6d2e278961eb22345ec49f", size = 2075402, upload-time = "2026-10-08T14:30:14.968Z" }, + { url = "https://files.pythonhosted.org/packages/42/f2/727d2b23ff6ca67c9c050c4ff4e5abd7b599d9ebcd819a74141d3b11c7c1/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50920d66aaab60dbc6023c3035d55fbabfa58b6d205d0b39d47d820c4a7a13a8", size = 2108552, upload-time = "2026-10-08T14:30:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/4a/da/a4321058eea61f0504366668a09472a4c37514be30cce77967c35ab7ffaa/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586699b43066ca6038f96bd71d0eff0b6c292929e3d66ff63d5dc79d0f06d589", size = 2267745, upload-time = "2026-10-08T14:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/50/a6bd3c397b609aade207ffb78ce46f4ad4e29bb42a39007c87ee00e778f3/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68421ba548f6d86c7dededd70bfe530b4faf67eb811c53c70a1be69438d3ce99", size = 2154371, upload-time = "2026-10-08T14:30:34.965Z" }, + { url = "https://files.pythonhosted.org/packages/11/3c/2d32a1f945b9809b65701d2b72315bdcd700f13c093df5e60baaa784bc6a/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:23e0940b7d73f405d92e98b2a05b93b16db163be13b7478bb19e1db2e486ade6", size = 2087028, upload-time = "2026-10-08T14:30:37.814Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/c2aea271192d8c4b81afeea9d61de8f5dc8686a1fcd1ace80e70a482fbce/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9913fa2b5bfa6111c11158cf481bb13544f8bd5d10382fb6ee2612b16fcf28", size = 2259611, upload-time = "2026-10-08T14:30:40.581Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/32128d5498b95722fe914a21a25f59edd7dc8917474f47924005682532a8/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ad3532291deedfdfcad3cf5d351d0076d646c68a0c63869a1bebf87c22159600", size = 2268636, upload-time = "2026-10-08T14:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/7dae31279e07aaa63dd4b17756f4ebd5647aed6bee5581ee3b3e85d82a0c/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9279f4be22bc3765dd9f61f5b4d38d9cb219d167bbd6f2e0ffa3368b1b71b4d1", size = 2305416, upload-time = "2026-10-08T14:30:46.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/61e47defbd490618fcde2641f80d5c077f2961de62e88500edacb49297f9/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3b2f3e44af4c6512dd73557621385a71b18094c8be81d60e5a8e73a8ce96b7f4", size = 2466713, upload-time = "2026-10-08T14:30:49.214Z" }, + { url = "https://files.pythonhosted.org/packages/61/57/81327752c007834d684b0b7d20ce00087206f512c7347a6236fd89ef0223/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:593dd4f24abeb3ed90222f98bc6eefcac68cd5aa96fc7745f93862a802952bce", size = 2481971, upload-time = "2026-10-08T14:30:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/48/c3/e321d3a4b2737372cf775ffdd1a02428c7d2791ee5afc9f228793a0ee245/pydantic_core-2.50.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b123f9d8702106f39dc3af63a031ca8b5279862a3747ec6c03ca49dbe78b71b9", size = 2337958, upload-time = "2026-10-08T14:30:55.045Z" }, ] [[package]] @@ -1534,106 +1692,122 @@ wheels = [ [[package]] name = "regex" -version = "2026.2.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, - { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, - { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, - { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, - { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, - { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, - { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, - { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, - { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, - { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, - { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, - { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, - { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, - { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, - { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, - { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, - { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, - { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, - { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, - { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, - { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, - { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, - { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, - { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, - { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, - { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, - { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, - { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, - { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, - { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, - { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, - { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, - { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, - { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, - { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, - { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, - { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, - { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, - { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, - { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, - { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/6b/6dea87689c3a06a6e79d254bf824e6f3e3d724b5ba027c6112559aa6cd2c/regex-2026.9.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe", size = 495413, upload-time = "2026-09-29T00:46:14.51Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a5/0c791a0e83ad1013d262c13247c4c77e0f4a8d05bdc167df96aba6681c0d/regex-2026.9.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1", size = 295145, upload-time = "2026-09-29T00:46:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/07/9bf3607d8d13a12e436ab9d63f9791e10706827d535695b23964ad79fd79/regex-2026.9.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075", size = 292123, upload-time = "2026-09-29T00:46:17.646Z" }, + { url = "https://files.pythonhosted.org/packages/64/6b/32c2e6fc617e1d3f247e250fea31a9a35b1265bd32f585968aa13b9999b9/regex-2026.9.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413", size = 799537, upload-time = "2026-09-29T00:46:18.976Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/f041177f3c7a4606f7c81a95fe7eea03e2a0c4e8bff9e439a01432cbc9f2/regex-2026.9.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71", size = 873255, upload-time = "2026-09-29T00:46:20.684Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4e/a78948e11dd715e0e46716c2e0f3404b3fe6a44e2a2e9abdc7d965cab2b3/regex-2026.9.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783", size = 914570, upload-time = "2026-09-29T00:46:22.599Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/aa08d1d2b294894b365e5f8ba5380fe3f8546acdb81f10639dfd74209c37/regex-2026.9.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65", size = 804978, upload-time = "2026-09-29T00:46:23.981Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/1b03534c4715aca3b564416d28d518083ed4dab3bc913267600d2256140d/regex-2026.9.29-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85", size = 779297, upload-time = "2026-09-29T00:46:25.318Z" }, + { url = "https://files.pythonhosted.org/packages/76/a7/378f6f558d9e4444af315a307c5953565a511d1e3666f1bb7bdc82012b6b/regex-2026.9.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621", size = 786961, upload-time = "2026-09-29T00:46:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/59/13/79f0b1846f5f342f92ddbd4b27b18bcb86da96d902c1a0be26520bde98d7/regex-2026.9.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1", size = 863234, upload-time = "2026-09-29T00:46:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/19/05af70dec9f2eed6ba34e08d2dcc6a48e7ae5e307659d5fe4201a5d7bbee/regex-2026.9.29-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704", size = 766487, upload-time = "2026-09-29T00:46:29.941Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/9c7486d4afe8fdd1fe0ad60139f8aa91427381f409af6a29b609d8fdcb3a/regex-2026.9.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3", size = 854792, upload-time = "2026-09-29T00:46:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/26/c7/49d008ff5f741d9a9799d7315556f3a12b983ff0fcd2cdfb62904bedafbf/regex-2026.9.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5", size = 793050, upload-time = "2026-09-29T00:46:32.775Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a1/46ba549e65562ca04608b24179b8a7bb6f146ae0e7c6d7f5e70f3339c8ba/regex-2026.9.29-cp311-cp311-win32.whl", hash = "sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b", size = 268940, upload-time = "2026-09-29T00:46:34.179Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4a/aab232183c70fdcf77bcf0c51819da02ec522e393e6a0bf00bcf2142e21f/regex-2026.9.29-cp311-cp311-win_amd64.whl", hash = "sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725", size = 280641, upload-time = "2026-09-29T00:46:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7c05954af0f51de376df2ba97f7f78a8b79334c7e5b3d2d9f2aead1f4d3d/regex-2026.9.29-cp311-cp311-win_arm64.whl", hash = "sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954", size = 279411, upload-time = "2026-09-29T00:46:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/48/3fdcde9a0baa84d7d25571223265d6e434e114763b438601d54a8028bf3e/regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf", size = 497903, upload-time = "2026-09-29T00:46:38.938Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/4ee3e97c76f53940488dfe7a7e18705e78daac8cd7fb161d246b9e328449/regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d", size = 296416, upload-time = "2026-09-29T00:46:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/37/14/f3f0ba083d2094392d5eabf56db5ea6ba469fd6e927afd187042054ea68a/regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba", size = 293633, upload-time = "2026-09-29T00:46:41.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/67e7a8ce17f1aea49df215564048efb49cc8c2b31a0e0fc30f36838f8516/regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca", size = 805885, upload-time = "2026-09-29T00:46:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/25436bcfd4d2260b4b4090094d55d7ab53ec8a1ab4865a0b8bcb33c7d5c0/regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242", size = 878344, upload-time = "2026-09-29T00:46:45.328Z" }, + { url = "https://files.pythonhosted.org/packages/97/e6/a09ec3a23ae41d6179880e67f0aace9284b2d95f2d7b326eff203f8eec5e/regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619", size = 919181, upload-time = "2026-09-29T00:46:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/26/83/d2fbd2e4e3afb1167daa825187d196f313cbaa1a4768f311fb041bb0e3d2/regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0", size = 807783, upload-time = "2026-09-29T00:46:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/46/0b/eb429a7016610d44fc89a597163f8c9127505f0d7dc724dc9effbb6a3ac0/regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1", size = 783465, upload-time = "2026-09-29T00:46:50.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/07/58a3c0153c7476898430f6a7cf3d9062a1d17fbea4f43399ecaf411c7b4c/regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a", size = 793519, upload-time = "2026-09-29T00:46:52.396Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/161b94d39164520e21a7befe0245569bf7fda4c7cf1fc4e2df2b5def49da/regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d", size = 869293, upload-time = "2026-09-29T00:46:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/3b02ed829aa2decdc1955d222bd1e2f99d1c8bb4873bbb9a66b2f0a36bff/regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf", size = 770239, upload-time = "2026-09-29T00:46:56.106Z" }, + { url = "https://files.pythonhosted.org/packages/42/5b/ba61f6fe062eb8562e742367d177bb75370434138ef6c9d2a27114f8d613/regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71", size = 861973, upload-time = "2026-09-29T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/767259b20e8a842948990f5e99138d6c077248fd42f8b5468b1d9ca4b814/regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3", size = 796470, upload-time = "2026-09-29T00:46:59.236Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/2566c4ba849b68a8ab81a6bf428fa79d20aae7ddee83979103c0381df254/regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23", size = 269327, upload-time = "2026-09-29T00:47:01.135Z" }, + { url = "https://files.pythonhosted.org/packages/93/19/489bc8db91196381c935752df01ba3f607140daece33b78d88573f028e64/regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649", size = 280334, upload-time = "2026-09-29T00:47:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/fb88ba779d0e5e7d4b0ec1aceeb13845948a2cb876bd572a2d1dfdba090b/regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2", size = 279614, upload-time = "2026-09-29T00:47:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/6080f7d1a6e7e36aa720f806ac93c035ba39c209ae6cc510e8ef4c0279c6/regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df", size = 497622, upload-time = "2026-09-29T00:47:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/c87fc7a2e21a42f9d57489db32951c37eef56d153840459a80d464f0321d/regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787", size = 296281, upload-time = "2026-09-29T00:47:09.764Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/aa0f4cde3bc4688c1d58b0cd8415edd708339bc0bc401a195b0b1e8c8f0c/regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963", size = 293472, upload-time = "2026-09-29T00:47:11.723Z" }, + { url = "https://files.pythonhosted.org/packages/90/d4/e835c487850ed922a8d6074f953b888c8ea99775c76b9ed5f8a4d72eab92/regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509", size = 805966, upload-time = "2026-09-29T00:47:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/ba8809847fbae8d2cbc71367c6ded510a7ec88bf52493c65efc1acf4effb/regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81", size = 878346, upload-time = "2026-09-29T00:47:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/e3da19fc3cc15ef67ab67e121e87887c3bccfdb683a7a9ec557c460ca5b7/regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab", size = 919250, upload-time = "2026-09-29T00:47:16.622Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8e/c1ed81f55f992f6aa0b699a592a50c1ce9e6d44ff1aee2c14c0537dcef9c/regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c", size = 807902, upload-time = "2026-09-29T00:47:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bc/5a6886eb470e41040e21e05b75024a18b6ebfe7ea400b72094a60f949101/regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b", size = 783514, upload-time = "2026-09-29T00:47:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/6d951d453b023c6edb880f1ba474291b53b8ce1cc438b96a9db6d791d991/regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5", size = 793466, upload-time = "2026-09-29T00:47:21.552Z" }, + { url = "https://files.pythonhosted.org/packages/99/b9/d5a41adc08360f5eee0dc4846c578f002366947211fc8af5a69a64ee7b9f/regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3", size = 869464, upload-time = "2026-09-29T00:47:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/d76c9d91f5d798e2e9e67f6f85ec4ae35445ac425f7454797311cecb80ca/regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a", size = 770278, upload-time = "2026-09-29T00:47:25.193Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/aeebdb540c620a0f7317f6d6fad80a47729ecf0599a24b5c34ec155351f5/regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51", size = 861949, upload-time = "2026-09-29T00:47:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/62/d0314bcedfd3586197e4596931fa220260eb2385bf53184e5b9ae67db24b/regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621", size = 796602, upload-time = "2026-09-29T00:47:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c7/d5a8c13a613facb03e0fb55c1ebaaf7bb35d8e2c1abe8bef8dca809fc1d9/regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91", size = 269306, upload-time = "2026-09-29T00:47:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/a7/bf93a3a6afa5f7bc16b7afb94ae581b01cae620b8ad56bd8f9572a985959/regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4", size = 280307, upload-time = "2026-09-29T00:47:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7d/388274e53605a86297f433a08102a7bbdcf9379d47683d307ccaefd88e2c/regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d", size = 279599, upload-time = "2026-09-29T00:47:34.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" }, + { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" }, + { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" }, + { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" }, + { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" }, + { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, ] [[package]] @@ -1665,110 +1839,137 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, ] [[package]] @@ -1816,56 +2017,63 @@ wheels = [ [[package]] name = "tiktoken" -version = "0.12.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/c5/9d848b7f408241171e1f843deb8bfa626086452bc9c78beee500829583e3/tiktoken-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c2edf09b381fafbc014ae8e018ed25087abb9a3dafa8465a0ea63c6558c47a79", size = 1094971, upload-time = "2026-08-17T19:48:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a9/d94302340304328961d6f0c35ca4e60617fbb57a5cf667e2ed1692cb9e57/tiktoken-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cd8ca1305c1c902fe42c486165f2e4808d9997625c98ffb05b9e0366d99d3948", size = 1042916, upload-time = "2026-08-17T19:48:41.541Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b6/31da98ee871383509cae2ba96a9ddef1965e3c4f8cb6dc7bcda3379398db/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1f83081065ee5833d35b49e9180f3d8d15622a603dd1c435da0da6cc12b3662f", size = 1188650, upload-time = "2026-08-17T19:48:42.729Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/8c5dddd7cb67f6571d154a58d7c6e2f07da54bf84c49b6a1839965b7c35e/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7665f6624e052e5e7f6a36919ab69279decdc976d7b16b4fa15e1897d0513", size = 1206378, upload-time = "2026-08-17T19:48:44.013Z" }, + { url = "https://files.pythonhosted.org/packages/d1/04/522ec59d30dd9a2f3ab837011cd4fc5d1178dc4a2fa07c9fa4b90af6ba9d/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:144a3fc369f92b7d548995217c5d6e84038d3572157a0f6f34080d65291d0f78", size = 1253694, upload-time = "2026-08-17T19:48:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/9019e272bad188a1c61ecf44f25a9ba2368744644e3ac1f3d6516f3c9e80/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:151d37a150c8f3dfc5f4345597b10e101876bd1bd13494e0185af6b508758d2e", size = 1317873, upload-time = "2026-08-17T19:48:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/fff1217240343c0c11b5938b98aeae0e3a266cacfac25f86f91cdcd748f0/tiktoken-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:c77d4a3e1deb2707819df92046b89aad1ac81d27e07616b797cbff3f62c037da", size = 944395, upload-time = "2026-08-17T19:48:48.028Z" }, + { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" }, + { url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" }, + { url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] [[package]] @@ -1882,23 +2090,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -2027,105 +2235,173 @@ wheels = [ [[package]] name = "xxhash" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, - { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, - { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, - { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, - { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, - { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, - { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, - { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, - { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, - { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, - { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, - { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, - { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, - { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/58/bc81e25cceab76ce4b400441e3a43312bf3887fedbb2e5f80cc5a7dd7f75/xxhash-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b4477edc03091f51f5309406d230851c23cf4822029e3bf40b8df53093fff1c", size = 38473, upload-time = "2026-08-17T08:20:34.303Z" }, + { url = "https://files.pythonhosted.org/packages/64/8d/d95e810c9a2930906f1fbd0e38f77554abb8e042a190aa9cbb24da39e9a2/xxhash-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:04f9a24de11a6647666d5302fd73d6a5224ce50ddc965fb0bb44cee736e6bd7c", size = 36228, upload-time = "2026-08-17T08:20:36.641Z" }, + { url = "https://files.pythonhosted.org/packages/72/be/ebcded2a32ba664a17a1737a91b6baa298bcda6942acdad81af81660b74c/xxhash-4.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8c5ce76b94ba49f3be8a8f2611abc6564210702c72ac9e237ca2bebfd17794", size = 253292, upload-time = "2026-08-17T08:21:27.206Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/72d31d787d988d1130253bc34d249c553f02c9387e7dda789b6d5aa7963b/xxhash-4.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4c8842fb19d78b5e8c2a52baf4c8357658cc56c62bc822b86ce0f942f28e286", size = 276545, upload-time = "2026-08-17T08:20:26.726Z" }, + { url = "https://files.pythonhosted.org/packages/39/ae/048f3b1f283a340bef3697fe5a9d4f10de1696a379af9af6b2352c24d82a/xxhash-4.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a43418e1a90b4809a9caf64aeb8b0696e3e1f300a323acc1e6ee2f93ae319fcf", size = 296295, upload-time = "2026-08-17T08:20:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/e9a593c81445c8e8d669402edb8264144624e7e5e2406df7d33e3c164d90/xxhash-4.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3662719007e059abde7eddacf8517142ba076ddc7b30c807260e57d28c3c191", size = 279966, upload-time = "2026-08-17T08:21:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/29da2b166955a300521c0abd498ad4481916c3743949b106192b781f58fc/xxhash-4.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06d7fbd609503c3be5e65cdb6bb2f040d6a98574404e2e1d5c60815c97fff4aa", size = 509850, upload-time = "2026-08-17T08:20:32.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/ff/e39e1900179ce7f0f23e7000d9fc672471a8d05b6b2dc657cb496c8975d0/xxhash-4.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:101aa300de6ceef3d9c77569706330d8921fc45dd82bceed2084f1e9f2557a24", size = 261005, upload-time = "2026-08-17T08:20:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/89/79/76ee26720d13219458f4b2ec0b22f539fe2bcb1f83dc22a24be4cda4e285/xxhash-4.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4296fcc790876a8b0f297edc83d3b088457b774d8f67b4636807f8a2ec69a79", size = 339620, upload-time = "2026-08-17T08:20:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c7/56b53252d64ecb2f3a0d283b41033561b1bec9c268095150153b694c2e52/xxhash-4.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:57d7fa8f23908d173001c21a9e82bfc6ad997d1b6c270fb121812b7ed158891c", size = 272558, upload-time = "2026-08-17T08:21:33.883Z" }, + { url = "https://files.pythonhosted.org/packages/fc/76/83101a2f2ad3eb6b4b5d571e0aa394a576e3e23121707d507d80197ac385/xxhash-4.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85e402dab0f9acd3604539747c6fcc57dc188a18af6ab07eb8189351cd32466c", size = 300417, upload-time = "2026-08-17T08:20:36.183Z" }, + { url = "https://files.pythonhosted.org/packages/34/5d/5be1166ec4fc4bc896e508dc51189a2dad200b373269a430e214fb152693/xxhash-4.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fb59a0dd61fb2ad481c03fda399d78ce57dab6bb62c2c8fdb446a7ba4754b89a", size = 259286, upload-time = "2026-08-17T08:20:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/77/6c/42f6201f13278787c58a6b3a1cd597b47e8665a7d4f68a3440d001c05a75/xxhash-4.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0b20a06454b34f1531fc677c54efe2ecdec691ef9224f7fa919bf2c1363f7ff1", size = 278230, upload-time = "2026-08-17T08:21:47.62Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/3cb7f149a5a469c628604c33bec6d79764698b315700a4bbf1c6fb15622b/xxhash-4.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f7db035447a0ac8959aa230c5d36545ecf9f547413eb1711c0ca6f0ba1418925", size = 329846, upload-time = "2026-08-17T08:35:10.922Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ee/934eb1e11f4d0e95f3828825f8da4b6d59e338235d63edc3d929769262d2/xxhash-4.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:34ed93e20bfd98d722b902121643791eeb4b1641871e2dc63d0d4c2d93f187df", size = 477285, upload-time = "2026-08-17T08:20:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/77835dcd7aec970db74f5724c94207ada83b3e2f5e1474ab44b9c8fe5ea5/xxhash-4.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6247f5e23ee94f2557ac9dab738a336f607c6ff476fcf66ca70c3aef5eee15a", size = 257552, upload-time = "2026-08-17T08:20:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a3ce7a1a9c4ec9ad8babba591a996a71c474ff9630c5fb04c8c9d8b995ca/xxhash-4.0.1-cp311-cp311-win32.whl", hash = "sha256:348c8f288dc961d6bbd1985c8152a3ed7a85c95df00e82320f0c5215d922a399", size = 34630, upload-time = "2026-08-17T08:21:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/60d2170e8cda0891aab25dcaa74797b420c3c6cde8a5bc8372f17b30c0cf/xxhash-4.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:ac0f291ab6485bd71f33941f9b92771318332a05d505460b41e893a549caadc0", size = 36992, upload-time = "2026-08-17T08:20:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/b229a39f9bbbe30cbcf9afaaa8993cb65286715c90a3b058c3769196ae02/xxhash-4.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:72f34834518157a75e7090f328ee7a16c70c804cfc7c694fa069cc888e9fc03e", size = 33289, upload-time = "2026-08-17T08:20:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/c707286b527722f776e1fb81dd202c45623355ba1a2972337a2a26075b2b/xxhash-4.0.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:8c9fe122444e129881afd1d4d1c7ac0d3ce2d91b68c2b40173b6025ff1c31f9a", size = 43639, upload-time = "2026-08-17T08:20:54.945Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3b/bb71639a0f95635f61936a6f2653599c4261b645ddddd8d00f9dfe3613e2/xxhash-4.0.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:1f3346c5c287ac3c7f38b20380f55e8768230e7252af59fabcf3b87ab21e4256", size = 40657, upload-time = "2026-08-17T08:22:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/76f3f5385faa9886a36f21fcc603f40b4c0c40ce622382f133160c48b4d9/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4e5141543c7f7fe3087500bbb4ac2845cb528a980aa91f8f1e661e2292ff4a5d", size = 34708, upload-time = "2026-08-17T08:35:24.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4a/f48f0e3e1b1ab072979fff2a5be899234e28090883e8b519d0b10215d708/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f09ee747e2a5f876cc5ad56947734811828335e13b403dd8ea1e06d77a9dd48d", size = 35650, upload-time = "2026-08-17T08:21:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/b73d7472b196101ad1f57ed0674af3af803ac3e9ec2feadd650a7b262562/xxhash-4.0.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:acf52474b2494ef66dc7e0fb6d5e2b50c18313039ad4d275fbf9f9907c804bc5", size = 37958, upload-time = "2026-08-17T08:22:10.616Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/024946ad8fa532074af4e4380179da54b7ec9facc8bd0b279ec0fac4e63a/xxhash-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1b3cccf75eeb5b01639b2feadb042a8e07889293b7ca72fa2985e7dcb64763cf", size = 38032, upload-time = "2026-08-17T08:22:09.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/934af8d99bb5885711006bec30a691f728edd513d2c40f053f887d8e7577/xxhash-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd878d32f5c6cbce9783f8d6897561fb772211edba9dde49d85672b88ed45276", size = 35895, upload-time = "2026-08-17T08:35:16.53Z" }, + { url = "https://files.pythonhosted.org/packages/20/5f/a8011f6a1558f7ca66d9077bb4f192b1871afcea62fbd5733605d2015755/xxhash-4.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:41e579025a6e13a99e6d71e39c9cfc621a0dcdbbf19106325e145fa858f2d794", size = 259464, upload-time = "2026-08-17T08:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/9665a44397547e7a3d58c0942425a976d58dcfd4b538f33220a312bf6912/xxhash-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74379a577a9f3b6afbdedf1b90e5c7764467051977f18a326d7d607336d743bd", size = 283949, upload-time = "2026-08-17T08:22:17.003Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/78774141266457468f29f3f5803092df4db87d8148ba74e4debd041649db/xxhash-4.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:acb31ecdd1a97fab5cd39a84ee9f515e727d319f796fec48703b8339b9998360", size = 303898, upload-time = "2026-08-17T08:35:27.951Z" }, + { url = "https://files.pythonhosted.org/packages/59/48/d78d22de576b42528bff87c14207de50de4f0b888221a50ff7c9d675d670/xxhash-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b7875ac1a2edcb691f27642b8b94b904baa6bcecb7d79c72df2228ba8cb5c51", size = 287241, upload-time = "2026-08-17T08:21:13.042Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/7a1755a59c59fd46176f293bbdd99e399a6537ba9537fc723aa4d1bf6e27/xxhash-4.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4751f1d7eecae6b2d2a773630f1a7248f125c9a92a456694d03c15bceffc9d68", size = 519856, upload-time = "2026-08-17T08:22:15.35Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/76580c08e916507859b0f335393cb5fdc59452c4402edbc6bcca6e47e7df/xxhash-4.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a51b061d54cda8b83e62c44458bfbf0dabbef9b975dd9649952ba5076b9f349", size = 268572, upload-time = "2026-08-17T08:22:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/1abde3e07b8f2077a38b4fbfaf764115008bfe0ff03bc7756a52c9fd0607/xxhash-4.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74a164e8b63f1e9cf35c9a7809d082b033d1a00e7375d5d814415436e7867e57", size = 344967, upload-time = "2026-08-17T08:35:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/80b6ddf0732eef48a8b5fe717398274794392bd6dbe82af38d189d214772/xxhash-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f5e5c6df4b703afcbe9352d238a51efd97c3b91fdc3a2052e40fdacb1e7505f", size = 279956, upload-time = "2026-08-17T08:21:24.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/11cbc43c205bf81fad50d69c7319cd1b1ccc01a66cd4fb8766357126c43d/xxhash-4.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d54b8ae068af532c8cdf56abb9e09a60fbe7b10792444c9c27987bb6d3b450fa", size = 307583, upload-time = "2026-08-17T08:22:22.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/11/cf0bc07feb2791045b6ac075d4bf64f1a5beedef2f46ae70d7104d63a19f/xxhash-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1749f0688020209fe0d357ce1e1cd9ec9c6161ed0405ea949d24581c4c43fa91", size = 265848, upload-time = "2026-08-17T08:35:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/7ada4bea2a2795073dfc42d96842930efbe7a0c1857ef4b522e4e90e5d83/xxhash-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:94ac8a6b8c47951173f0b67bf862bcb971bf24e493b9fbbdb0e010cbbc7d9f54", size = 284409, upload-time = "2026-08-17T08:21:23.156Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f4/d8ce83dd6b99ccfbdadaf2db968ae40334d2e5f73a0297e593b9ddb3df39/xxhash-4.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a33de7633c948ab2dc144af370a66e7e7af29b425dcd0f7e4f59689fb9391b53", size = 335921, upload-time = "2026-08-17T08:22:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9f/f47d8724bd8bc45b395b06b7cacea2dae0d00031af1b707184a091161df6/xxhash-4.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:247ece770647c0aef080561fa996f9774b4dadce2d0c42eeb98229db7dcf820d", size = 487023, upload-time = "2026-08-17T08:22:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/2d87098f3371cc1e42dd04d2285ad56bca4c56667bc501bff02d2b9fd6b5/xxhash-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4553d36cc0b7fce1f35ba8a94dfd775aa3ed12f5eab2dc3b46ac75a0706b0bb", size = 264333, upload-time = "2026-08-17T08:35:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/93795ca5898ec7d7d0455283ad261c0fc76b4f0c0a69e86233bd7badb0bd/xxhash-4.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:87aa309a93bd5ec13f14309a305ff4e9bf74c5363fc46c264c0a22edfd5b0670", size = 20581, upload-time = "2026-08-17T08:21:39.207Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/926f7335a0a1647952c00421e8da877f658094f61336306c7cadc335c94d/xxhash-4.0.1-cp313-cp313-win32.whl", hash = "sha256:cba763d84b06bda2c38d5185dee76f1b9dfdc0789e96e476d9e10005526d0788", size = 34449, upload-time = "2026-08-17T08:22:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/8a5aeb811de093bab3434e77eff0e9461624a1a56a6a93d315d080aab2aa/xxhash-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:97b94fb29abf21f5f0bde15f7dbdd3a4aa2dc59f37026adc7b4bee8563b84375", size = 36520, upload-time = "2026-08-17T08:35:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/14/97f3c74000ca36955e9cb86f6d270dcd5848b5c65afa623453f5cf2d83d6/xxhash-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:08ed8da18cd4fd0a6a5d6a444852d8fbd0e565388a74a4937085451b5f1a312a", size = 33428, upload-time = "2026-08-17T08:21:31.713Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/ea406a02b561d3275232ccfdb3e29df80f7a65414940e3a15721c7bea40f/xxhash-4.0.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:af05a3f650220a6c59fa0ad2410249f2d2470a05225807c378fb67458693f8df", size = 43747, upload-time = "2026-08-17T08:22:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/b0c94d61ccf6b5d1f8847b58ef8f923125ac4919ed5bd0eb082750ca7cbd/xxhash-4.0.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:a6e3653df1a70b8ac4191216324242e4be2bca18c9a7c10934e1bd56dc7ca15e", size = 40749, upload-time = "2026-08-17T08:22:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c5/8085881a538983be0fd1c865d5df236242fea496044e2c8ca32b9f2ba39c/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4528cf80ebbbf57d40edfb31521ae265daa6dd636d615b1cf0ac86209579e59d", size = 34734, upload-time = "2026-08-17T08:35:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/8803d13c968fc75ca434eea991d29ac5fd8a36b4afc9a6a9803c53933db4/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:90cb2a1c9cc503a054a19612b48ff6e8e47805f618bdb3224a07568aad03a37e", size = 35671, upload-time = "2026-08-17T08:21:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ad91d7f0fd294190d37c08236fe661f5c4e3f83dcd1a121877a2e64681ce/xxhash-4.0.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a949b072ea59c6eca0811ccd9e95133cc50d2afda8d464b5b077c78f78efa269", size = 38094, upload-time = "2026-08-17T08:22:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/2b7ebdc1869caca5f02c4cba8379b631050d3c3d4adb9187e4dc1a6b8d3c/xxhash-4.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:79a3203aadf39637869dfea1185227d8452844d78b837e54fb1117b4d34ba5c3", size = 38244, upload-time = "2026-08-17T08:35:38.081Z" }, + { url = "https://files.pythonhosted.org/packages/90/9d/f66cf6935f528e575f1ae4d6560d376e7587569747186f4fae8777cadc1b/xxhash-4.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9f3848ffaf010bdbabdbf4c25641fa258b6227ff27bc74a4d06edef521a4873", size = 35904, upload-time = "2026-08-17T08:21:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/34569d7b482f0dc060074faafd163c588f915cbc3e3e218f1ffd8a3ad340/xxhash-4.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9283d9dd6b44acad35118e2976fc763a065509e4118debdb61916ec322ed17b9", size = 259595, upload-time = "2026-08-17T08:22:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/a2370acfcd48732cf5c2b87f06cfbf7fa51c0ce0dd736bde42939eb9ebf7/xxhash-4.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c7c642a0f79c3e3cf2965475507574d3d1a50ec71060039d60cb87358667cb2", size = 284279, upload-time = "2026-08-17T08:22:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/08/15/17d33c24e6c4a1c0b9ddc5584f0c25d51d48b34bacde1416a2235a19db4b/xxhash-4.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96dedccfb09a73a25751053a183159b88f4ee75f388df8166040c152ac0531c6", size = 303973, upload-time = "2026-08-17T08:35:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e0/4ec0d69ad5738729098a61e631b7ed2df22a922b0e03014b597c72bd863d/xxhash-4.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81664268dba92e037b740ecf37fa02f1cab4a391f93f28e35792b3341c60648f", size = 287535, upload-time = "2026-08-17T08:21:52.158Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4f9b17e7a9eb71c65548ecddd9c18b84e3c18ca41c4d436ad2a3000d3f7b/xxhash-4.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:839f58c5bd9989875be0fd28446dbf32cace2c2cd8bf2f6762acdc38a95cd1aa", size = 519257, upload-time = "2026-08-17T08:22:43.272Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/3276b3e743b8ddbed9c3f71c76d9dd6a75d72aa4e678b1447b635cfd92e0/xxhash-4.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa44b4c7c5d0ffa31356b4428659516c0e47647825c74079a296b3857b6d99d", size = 268190, upload-time = "2026-08-17T08:35:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/f1555de3c96721320930dbb7988c8482d82b85970076aba1a8d40e83ad43/xxhash-4.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e681a6fc7e4f715252b9b5acfb30536ec7dd1f75033a32dc617e6fa95af1a3fd", size = 345553, upload-time = "2026-08-17T08:21:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c28908f27007087b61139d290f908dd827ffd40b88af0c43f9e1a1a7ffd5/xxhash-4.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6301d92545c591ad31c3e050aa40a5f8a4c16413f1f9e6f9322c6f0f9d2b736", size = 280499, upload-time = "2026-08-17T08:22:52.236Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/3ef57622c65816348f8196273485baab4752aae064959901e85cd867e067/xxhash-4.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6efb8f21cc136c79b3e5bb747c8682d37916fb202cdbbc32182de5c4e47f821f", size = 307211, upload-time = "2026-08-17T08:22:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/5804504bbc808968e57d6a50286dd8f8cc06e0ddd6e4ab4b1dc89ae42f35/xxhash-4.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760de77279e9cf9c81d012ce0705cba13afccee9b09c480f17d778c8c5cefae8", size = 265865, upload-time = "2026-08-17T08:35:42.727Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ee/8572fdfd70e7aaaf150af899871c2cc0bb88c3295ca82172a31e04ca5168/xxhash-4.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a16a3fa6936e36bb1414d16a6bd012c9033e5161b68b426805b61d895392437d", size = 284545, upload-time = "2026-08-17T08:21:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f8/6eadcca0904660c848b466524e82a233d16c9d2d5258433aaf3546142d86/xxhash-4.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c3c4b9aa9a27196b921197f7daf9e6c1412739df06a99cfa6e923879362eff6", size = 336022, upload-time = "2026-08-17T08:22:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/4aa107b81602d6d6d09ab5a607c530d2d3a6b28e2e9a59b01875bd877c54/xxhash-4.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:863f3d3b44110f7243e86cf994aa5c5d88f2348b6e84ab4402fadadfbf9f7da7", size = 486671, upload-time = "2026-08-17T08:35:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/b7/b2bf9b5301e9cd5f2e335fea8da0f5cf209a6594cb1fe77754774ad4a6fd/xxhash-4.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63aa52659bc32bb9bd7cb5caf523b4d14429a477762cfac886132d687c1f80fc", size = 263744, upload-time = "2026-08-17T08:21:56.165Z" }, + { url = "https://files.pythonhosted.org/packages/0b/96/35b1c02177ae26234892c2310fb4822ba62411acccbf425ab8f9fd99354a/xxhash-4.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:67e57b834e07ed973cee7b6da1548ff28a56458d77696fd2a5f397f340694848", size = 20563, upload-time = "2026-08-17T08:35:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/a06300b165fbd6b0cb4a9742987f2e997a9f447ce3bf7c6ac97b862ce62a/xxhash-4.0.1-cp314-cp314-win32.whl", hash = "sha256:b6c1f9c59bbe593f88a0aad30be4150f15bd57bd64efb95feeabcb8e563f1ecd", size = 35151, upload-time = "2026-08-17T08:22:44.283Z" }, + { url = "https://files.pythonhosted.org/packages/06/96/c5b37296b78f80fc97124c0fee0c7bbd1bdb6f3b18bcd8748bb113b2d8fc/xxhash-4.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:da544672efd9ad76077928a3e6c5d894e52ce82d3bf14002db4a1bf17d1a36a2", size = 37156, upload-time = "2026-08-17T08:35:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5e/248f9cd169c2fb62236bedfba246d213bce728f74901e99047e3f3c55875/xxhash-4.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d0d24a4f3fb63852cd09af46ae4b7a4d00cc8b8615a046dca543786e728d1056", size = 34379, upload-time = "2026-08-17T08:21:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/db1d37c0da0324d0298f6abd931ca1d4736e049d9f2081230a8421da74d2/xxhash-4.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:349775ac30372b344d2338b2a168c0a1312a644194da25b8bec476d55761a128", size = 38656, upload-time = "2026-08-17T08:22:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/e18998ec465fb977bc74272e5bf3c2e886c13b014cbef916cd607802c709/xxhash-4.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:43e5f9169e73d0f0db33b5f6b8554bcce69ac278c966daf83d5eb4eb2f13829f", size = 36306, upload-time = "2026-08-17T08:35:52.853Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1a/b83f86f8a987a3cbcb7e005a6824ff64aecae35abc1395a0d44ee16c3319/xxhash-4.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a252fb862b0ae2590587e625f47a0e03da05cf0205e8830b67b6596c06038b1", size = 273729, upload-time = "2026-08-17T08:21:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/2db15aa8508e0cd5b632927a53b98234f24039ea65377e6cf996c06d2d4f/xxhash-4.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2df3ca8757dc381e75e90a4d7995a6324f58a923c7145220a7b2c0231f66fddc", size = 301083, upload-time = "2026-08-17T08:35:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/ed759787ffe802bd8e31cfcdad3755cbeca2dcdafd2f790cd6f25d195199/xxhash-4.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bfed61996d618eb90d6eaae0178002e3466a28b06bfc557a7a3a7266378d8c5a", size = 312745, upload-time = "2026-08-17T08:22:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/f64b4a4cc8b51e950709207f55f7f56ae9c5af6631dd31d7fb443312418c/xxhash-4.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9761ff4a0ffa583fe850731ad24fe82c88cccb7a2294727db0955f3279a4cb3f", size = 301419, upload-time = "2026-08-17T08:35:50.143Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/bac313b8de073569b8db3152044a7cfcce87a3fa9698c18fe9f914dee6b1/xxhash-4.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edccc2ec58435a580f96a48a3ccae8cd0a480824119165dd90108718ad81ae6e", size = 534485, upload-time = "2026-08-17T08:22:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/16b5e419f24e59507ee05626d2bb0deafdb03f9f27783bc0785a9849602e/xxhash-4.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4741d42d59e4e5fa1a86c17ab9c27dc8ea459c700d91b6742fdb9138d9a516cb", size = 279605, upload-time = "2026-08-17T08:22:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/5787dd6e2d8d5b61256a5039f6b18c2193c7c1de4a2fd2413288d0d9c604/xxhash-4.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:440c401e146ce64bdb3beb8ff0c84677b6f21307c28a34779071cecee5d4d70c", size = 358924, upload-time = "2026-08-17T08:35:58.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/89be41991f3b0a2e91f940bdf3128852c3ed571cf560d98ad0f67024afe4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5b7979f71d06ae45a769de0699900a246d8cb632db1e8bfdc79ec019063a503c", size = 295305, upload-time = "2026-08-17T08:22:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5a/52ff0a0cc361aad393ff9a46ffe3aabbcf9c03d6c8f2612da7d553048276/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:62198213fc3e0c56e567894b318ba45834e007d065f84ba6dc9165d21546fc56", size = 320228, upload-time = "2026-08-17T08:35:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b5/91c60ff22c7f6cd5f6d7a5bad5a2cdcb4c33987dfa50bf13f0d856279b2e/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3bece52127ac20044311ee73567f9f0893b5de64f9028aecc90cc740cfd525a", size = 279414, upload-time = "2026-08-17T08:23:03.212Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/9685954804d47d0390871a64bec606a0d536406382d71a784df3a5883fb4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a865d2d470220e659220fdb59d5b6c4422802d8d6098e1324bc4d12444798914", size = 297594, upload-time = "2026-08-17T08:35:57.881Z" }, + { url = "https://files.pythonhosted.org/packages/89/62/b67ac9412907b7a07a2a0c08c3440b9e4480231a7b3de0767e87011e4564/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8580aab306888224074c7edeec734de0c3c5ccde65b2da4e6c9a5e28f7c0a1bd", size = 348526, upload-time = "2026-08-17T08:22:18.571Z" }, + { url = "https://files.pythonhosted.org/packages/37/ed/6723cc49a9f567d52d01fd7c1741b0f2e3a13e71d15f7ac49d753a20c115/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2d52dc7c33c1b83082b707f6b7814dc76d2faaa2ea62bd9c5fab4b36f83c087f", size = 499307, upload-time = "2026-08-17T08:22:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/7b10e101ab988d93b791023be7191d7661271d6ab31ac082276b9091042a/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a9f98af872355e0c02439e48583958eee00e60b928bb20476460d9d40cb7b4e", size = 274989, upload-time = "2026-08-17T08:36:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/7eabcc8d29cce40621443cff24c07d7306ef574b8956c47ac59f21098005/xxhash-4.0.1-cp314-cp314t-win32.whl", hash = "sha256:a14578102a6081465aec9cf73c76c3cd3f79f0709bdb3b8ae7ab0b54c9d8b089", size = 35482, upload-time = "2026-08-17T08:22:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/ca/89/2a4268e1971f63038b79fb75e3b9c8de942cd77acabbb0c5625352a31940/xxhash-4.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c57963970d359a72262f7fe6be88f945e2334d4bc41462b7f08c37b0abf35ca6", size = 37490, upload-time = "2026-08-17T08:35:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/7b/950ecab1fe4cf421d0a6211ddd9a0ac82e39e55c45a111ceb90953dc6c9a/xxhash-4.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b659fad79c99b0238c7ad7e9d7dbf4eebfea9097c2dba65fa0a4d18a25b29a2f", size = 34596, upload-time = "2026-08-17T08:23:10.001Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/7dc3b85fac10751613bfedb0e120734e0e8710054abad3f931e9d3843a14/xxhash-4.0.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:5adf927dca8c47fde7e683fe69efdd81bc865c4db1fb6bb00b391e2b6185207b", size = 43749, upload-time = "2026-08-17T08:36:00.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/bfac071c5b1c6d6a3d48ab1ab96a15e958a1d7061f4afc97804292d87264/xxhash-4.0.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:c30dd1af66a820820398b26e0d74e7a9aa43cae705924f23ed828cd8e5c26c3d", size = 40758, upload-time = "2026-08-17T08:22:30.209Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/49a260e685d1a74c56a69432a8ee0527ddcbd684a3c51f87edc3b75639c5/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1bc591533fc975614f7e13594daee76af96b8e1fbcf8de76c8773858fa9e7cea", size = 34788, upload-time = "2026-08-17T08:23:09.014Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/50d72ed2170dae872e1c0fe333d0908e0a2afbffe74c5c9037d5406a4b89/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:567cbc630302a46a8ecfd943b309ccf5372bb3718f1f3762d452df30f033bcf0", size = 35746, upload-time = "2026-08-17T08:36:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/66/f0/969deaa2bab3bfd5ad5b023442124d2255b9961eef6f797ec74eb8683bdf/xxhash-4.0.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e998cb3685b92101ec5de0fb4d9485cf01e50bc418211955c55d98064664cf4c", size = 38098, upload-time = "2026-08-17T08:22:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/45ed7d7b8d7b66202a47bf8ff3b77cea28d2ea54dfcdd202b4cfe043e3dc/xxhash-4.0.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c3074db513c81f764053e3da079312ecf85a50d8350c71f4cc0105d9662a9e6c", size = 38251, upload-time = "2026-08-17T08:35:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9d/45e7520a7856e13800a5dc8cd038d34c6372429465b163af0c5722f16918/xxhash-4.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3088dadbffa33c29e0518578430a7dff2e901a212e487aefa5faaa0dc06dad34", size = 35986, upload-time = "2026-08-17T08:23:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/5ad466e5fea18c9f9bdc5828c0506f62190061b4a1b0e688aa54969d0a9e/xxhash-4.0.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b50223d92df94d54e1a31469335a2c74b16692e6c1cb726f1e6949514458706", size = 264609, upload-time = "2026-08-17T08:36:04.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/8f269f85217e3dbd45e31e25e46cc26f3aff0e159ef05d228b4b982c778c/xxhash-4.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:427b62d62d4f967fbb10b82a3813e4875c2a6e7e7634739f17265b650c7f65a6", size = 285910, upload-time = "2026-08-17T08:22:38.589Z" }, + { url = "https://files.pythonhosted.org/packages/ca/30/2fc1a16ee0f9501d074b798ebfae52e24fa602c7117f5c4b81de71eada72/xxhash-4.0.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c6370189e8e66b7e608f533b939a9de092ddca6cce084ca0d3d414d2ed5b5d59", size = 306566, upload-time = "2026-08-17T08:23:16.895Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/08375cf2b997e1903663fe7525c5973b1987a4f8ad2b8d47463e9143f2ee/xxhash-4.0.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec1a470c6db94ac4589c203921e89ac1bc13e796a8b1784d8135e1893559cd3b", size = 287978, upload-time = "2026-08-17T08:36:09.296Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/90a7b404c11add9e53a497d06236152852490c3b2f21e468d97a58f26afe/xxhash-4.0.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f667dee0f867c42894b34e2a6fe26bf195c0ea4683d9d2b713db023f242c3a", size = 520098, upload-time = "2026-08-17T08:22:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/7fba10b1b17eb46308f09cc0a4ed513d74dff16b1e22a1c439f011c77129/xxhash-4.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18732adcc271741bd651c3e56fa519d8a237d2cccda01fe3afb226bf87f783b", size = 268273, upload-time = "2026-08-17T08:35:29.043Z" }, + { url = "https://files.pythonhosted.org/packages/54/49/c21b228877357a3be43eeeaa22182ad1685796f415390ada475922c084e4/xxhash-4.0.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b42a5a26607e4b2409fea174773a66f2dff9dfdbf2c1a851bb7b804e2c97535", size = 350164, upload-time = "2026-08-17T08:23:29.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/c15bb4aa33d94b78a5553b52e7fa1070565f0199925aeadec3871de20ce9/xxhash-4.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:99166cc98637e8bf550cda2aab07f4f1d5f899c45fbd721801aeabcc9d404824", size = 281975, upload-time = "2026-08-17T08:36:08.139Z" }, + { url = "https://files.pythonhosted.org/packages/18/7a/b1d0388315fe7752b7725b68a912667526a1dd48ed492fcc031ac03f4b52/xxhash-4.0.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6cf633df84d80a1668fcf61e330791dae46825e395549e7d34f376411e75088a", size = 307872, upload-time = "2026-08-17T08:22:42.206Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a1/037cb2dd8cf725c9565dfe3712b2915c0e0276a9154913dbfcbcecbeb672/xxhash-4.0.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:e259bb7e1e2d8de6b35f430f5c7220b1c0ebf3962d1ba7ec7545980d5931edb8", size = 268241, upload-time = "2026-08-17T08:23:23.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/67c44422d0ee082169b238ce24bd2796b82d7c21ed953471365df8c508d8/xxhash-4.0.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:704381264b36a18b9c62ecbabe2e71d0fc58c77c129c15355c989b10bf05b6b0", size = 284970, upload-time = "2026-08-17T08:36:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/254a5f51c4014cacc77a26f321372338b924f54e89efb730164ee336d850/xxhash-4.0.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e90b4bcf1d9eb1010fdaee7c9209fb667e74c0684f3ba17f9032bd7319da90c9", size = 338074, upload-time = "2026-08-17T08:22:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/64/03/f21c4830118d72ef3a958ce8bf2152f49e0d4cf200907616c9be6caf372a/xxhash-4.0.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a65785e653573fcd1e33062760ab4c3c3440e8e910765018e4b6ed4ad07b54a0", size = 487626, upload-time = "2026-08-17T08:35:32.768Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/268a689d741d7da649317eb4ce41760140beb4179aaf43a7216fdbe8100c/xxhash-4.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e3996ff9b6f99180357024336bf5749a8ad6476a9a2523e535c5212b995b12a2", size = 263852, upload-time = "2026-08-17T08:23:41.871Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/adaf8101cd7f143191a0b390600294d83924b32cb13770fde8803dce27a2/xxhash-4.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:99054b838b74d8d3995ea0d410976ae967c46207ae22d6ddfc535e809197dab9", size = 20569, upload-time = "2026-08-17T08:36:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/56a5eb8c993420fc07114c08f447a2b66ee996510b4764cb368b9b44c9f0/xxhash-4.0.1-cp315-cp315-win32.whl", hash = "sha256:6c45258a37fc22721395c09927cb982d3e7a83607cab15be7e2416501bd3a330", size = 35145, upload-time = "2026-08-17T08:22:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/67/c7/65f210db43e62157d0fef3b4d4d7b394821e7733c8bb4ece49f91410a725/xxhash-4.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:0ab851b45c70d4992be7cdeeee16f97a0b677408c758c4b1efb1cfe8030bfd37", size = 37161, upload-time = "2026-08-17T08:23:32.438Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/1a641d1d60ba219756d9ebe907ff0ecf4445adcf4fa96f6e3da57b91d439/xxhash-4.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:a5b21b42a01a343096a1c018d35e9b7aec9c7065dda53ae8da071e37478b2cea", size = 34378, upload-time = "2026-08-17T08:36:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/7698b320b251806d1249e513922a626f19027e104c829a611272250350eb/xxhash-4.0.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:44ab12e8cd17d4f001769f00ad465208b4bcb897ed29e65f058f74466b57a98f", size = 38610, upload-time = "2026-08-17T08:22:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/436497e775b647b3b3e9a4ffe8c76c59fa4aa7a9fab6447cb59acf1b50ea/xxhash-4.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45e88111ebe331de478ef8d4293efbe88f3cf8b863386c9a2357136b838e1af0", size = 36378, upload-time = "2026-08-17T08:35:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/17a4f8182b9257898aa2a77c2a45f70233eb8e50681a280e8e09d2ee76e9/xxhash-4.0.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf430c587f447a554c53768ad76b9846fe7c5632180ef6f69c4fce8b0552fbd0", size = 273559, upload-time = "2026-08-17T08:23:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/121bd5a5c5adb88e0da772c7bef61964cf9da92956a7a237c7d24c4351b8/xxhash-4.0.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adbd48b30e3f82c89fb2b3e6a87cdd28d113b190a5ed0ee2dee286323ee9a621", size = 299133, upload-time = "2026-08-17T08:36:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/57c7b6e04642ed738a0d08a31bed7fc63fdacb661d665f98739cc9751b62/xxhash-4.0.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e71b34978e77868cbf2d18c5206a4603f9c644dd7181bec5643bd40141d3b8c5", size = 314527, upload-time = "2026-08-17T08:22:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/8e/18/42793917dbab0ea1ff71458aea4875e17a7263f2797b798af048dc81e867/xxhash-4.0.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:488ca5c5e28ef56ec4bbb12f835b3f1cbecc5f3510062e70117bc6594851932a", size = 299199, upload-time = "2026-08-17T08:23:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/51dc92443923d8e908d5614f1145d8d696450f9d6c8f1abe243c6f2a0222/xxhash-4.0.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:421b94f3ba7067958d02e38960d987756347aa150df06df11aa68ae1af78c619", size = 531967, upload-time = "2026-08-17T08:36:18.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/c5/d0de77de09661fac71742c4155b1cd65e274f7cc277819d702b6c8ff2db5/xxhash-4.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f33cf0baa91eccd2cb7b62bf00f10c2264ef578b71dd33a12962e71a36eb4d32", size = 278764, upload-time = "2026-08-17T08:22:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/9a/589929c655aba1bfb2c41ee03e50eec1547c39c3042a66bda9c173a9614b/xxhash-4.0.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23a4376b4a3183cb50d4d2a3179f887a7773cc695eb2c908e551bec3221b8c60", size = 359876, upload-time = "2026-08-17T08:35:40.35Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a8/c1d8c94d54d91db2215565f4b4151c1593af3e6d27ac4c00fd1e8d714a02/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38c3d22129a6958846a3098d68bc8e661704461c0be4793ae28836e4690c8478", size = 293548, upload-time = "2026-08-17T08:23:54.951Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/85d8abca94508a4dd10561d9dea3e6e68843c6986dd6d9c1b3729c8622e4/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:87cbdec1a7dd930079671a60b249f3ca4e773e6fbd0676e21e36fdc9dd0f3b00", size = 318780, upload-time = "2026-08-17T08:36:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/1b/16/2b920ed456b9cdcfc99ddc20c3afe42f9f807ee5850773c12fd891f3c08d/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:6cbf4e21ef0890804b5bb9ad25c48f9c127758d7f6c66bef374efcacc63c738a", size = 277582, upload-time = "2026-08-17T08:22:57.156Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cc/5811b5997aebb8452047f5800d32fc50eaa29d0ba08d4e426f84450b9c2f/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c101180495cb4ba3617b279a944345c53a5e73b0c150053d1fa8d8af32de9579", size = 295116, upload-time = "2026-08-17T08:23:40.868Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/c2f3f9c2f4d6aadb79f17a9f1c9a7ee82638cc873680da044cf29537d2ee/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c0e6ccc2b19ec8a726b2e26062ac71ea63e15500d6bf85910e42481844fdffc1", size = 347065, upload-time = "2026-08-17T08:36:21.618Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4c/750cc642c92252e10772ec09e1a1d995581ba4c3ceb24f6e2d57c7ce47ca/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:8bcba9456242ebf180a04d9443812fd85ffe6bd12bda464dd116fcece8886ff3", size = 497208, upload-time = "2026-08-17T08:23:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/58693cb13d6395f39b6b9bb40c5e0db53a5df7c9fce805aa7e792f64a1a5/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:83b8c2013edb5dc1f9e7268b6496130705bc48d79c86bb8817b3d210b81a5513", size = 274338, upload-time = "2026-08-17T08:35:44.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/9aa9787586d9b3e92d63343ce7dc24f0f445fd9e74ff5d6e85dd82233df5/xxhash-4.0.1-cp315-cp315t-win32.whl", hash = "sha256:aa6ccc7f31018484d652cf52db020003433f3c9fa83189c028bd807d2adde503", size = 35471, upload-time = "2026-08-17T08:24:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ab/4615789c333bee331ac417885c50105715eeb8244bfc68d2bc37dcfd63ca/xxhash-4.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:daade8936c4deaaf7b01561324ce438ba4f885d717e9adc62b4d67212ad7d7bd", size = 37488, upload-time = "2026-08-17T08:36:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/49f718beb0c55d0411bc4bd90b50a3fbe5863a0e97a2f4d11682ba13d298/xxhash-4.0.1-cp315-cp315t-win_arm64.whl", hash = "sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875", size = 34590, upload-time = "2026-08-17T08:23:19.597Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4f/e0648288a17d0d1084ca4f7bef206097831988fc86af74aa1dff8f1fbd68/xxhash-4.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:554f87034635bcec47c5d72447bf3db7e02da1bf493a0ada010db28a76f891c6", size = 36333, upload-time = "2026-08-17T08:23:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/22/15/34b7f72e9b5a8bfd7e6178de9e1e342bc3de9f07111a5ae26c00506d9edf/xxhash-4.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3c2445edafc300cc40feb6a25a8356a971c30cd0bf47b5349c2ad74c508343b1", size = 33519, upload-time = "2026-08-17T08:36:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/4b/e0af324ccf701bf84a7a060bef11c915d45d9e3c5b9caf5b94d62ecb040b/xxhash-4.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bdd16718b63aa3ebd68aabb79021a40e47c81374852d41a306b9453141bbcbee", size = 47995, upload-time = "2026-08-17T08:24:14.335Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/cc7130e6ca6b41ab72eb6a03177b933c7c74145545c99a8610ecc208c449/xxhash-4.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b99ebaf9e816ac5069423b1367ee7e8078fbcebcf62545506bb0608d2f4f468", size = 42725, upload-time = "2026-08-17T08:36:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/1c/24/4f26ff9a7dd0998f6d1036bdddef7ce3e78972a74f7fffa7967e7bc3b7e4/xxhash-4.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f484ed57bb3e4142f9d6439568658c38be5f94b702ba00a1ff32c69783b6c66d", size = 39532, upload-time = "2026-08-17T08:23:57.556Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f3/1ac078fc8fceadcf066469acecacb35d2821cbfaf7d6fc5ac2107c7a314d/xxhash-4.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fac4832b638000106207bc44e44b9616a6a416aaee56c62b01d61f3705e49f58", size = 37252, upload-time = "2026-08-17T08:24:06.652Z" }, ] [[package]] From db923371a8cdbc916ab7e43e2ae849df50a5c45c Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:37:17 +0400 Subject: [PATCH 23/31] feat(scores)!: send scores via POST /api/public/scores (#1987) * feat(scores)!: send scores via POST /api/public/scores Scores are now batched to the public scores endpoint instead of being sent as score-create events to /api/public/ingestion. The consumer keeps flush_at, flush_interval and the batch byte limit, retries transport errors, 429 and 5xx with backoff, does not retry other 4xx, and logs rejected items from a 207 partial result without retrying them. Scores for traces that the tracer sampler drops are now dropped as well; the trace id was previously never read, so sampling was skipped. The trace-create ingestion path used for trace tags is removed. BREAKING CHANGE: batch score delivery needs Langfuse platform v4.55.0 or later. Custom score timestamps need the platform release that accepts timestamp on POST /scores; older servers record the receive time. Co-authored-by: hassieb.pakzad * chore(api)!: drop the legacy score create compatibility layer Removes legacy.score_v1.create, the CreateScore* aliases it exposed, and the script that patched them into the generated client. Score deletion through legacy.score_v1.delete is unchanged. BREAKING CHANGE: use api.scores.create to create scores. Co-authored-by: hassieb.pakzad * chore(integrations)!: stop passing the deprecated usage argument BREAKING CHANGE: OpenAI and LangChain generations only report usage_details. Co-authored-by: hassieb.pakzad * style: format test_media Co-authored-by: hassieb.pakzad * test(e2e): expect custom score timestamps to fail until the platform supports them Co-authored-by: hassieb.pakzad * fix(scores): send naive score timestamps as UTC The scores API rejects timestamps without a UTC offset. Naive datetimes were serialized with the local offset; they are now treated as UTC. Co-authored-by: hassieb.pakzad * feat(scores): hint at the minimum server version when a score batch gets a 400 Langfuse servers before v4.55.0 reject list bodies on POST /scores. The first 400 in a process now logs that batched score creation requires v4.55.0 or later and that the scores were dropped. Co-authored-by: hassieb.pakzad * fix(scores): match the JS minimum server version hint and log repeats at debug Co-authored-by: hassieb.pakzad * refactor(scores): drop the minimum server version hint Co-authored-by: hassieb.pakzad * fix(scores): keep scores unless the trace-id sampler drops their trace Re-running a parent-based, random or attribute-based sampler with only the trace id can disagree with the decision the trace actually got, so scores are only dropped for Langfuse's trace-id ratio sampler. Co-authored-by: hassieb.pakzad * fix(scores): cap LANGFUSE_MAX_BATCH_SIZE_BYTES at the scores body limit Co-authored-by: hassieb.pakzad --------- Co-authored-by: Cursor Agent --- langfuse/_client/client.py | 77 +--- langfuse/_client/resource_manager.py | 151 +++---- langfuse/_task_manager/score_consumer.py | 245 +++++++++++ .../_task_manager/score_ingestion_consumer.py | 198 --------- langfuse/_utils/parse_error.py | 38 +- langfuse/_utils/request.py | 136 ------ langfuse/api/legacy/__init__.py | 7 - langfuse/api/legacy/score_v1/__init__.py | 6 - langfuse/api/legacy/score_v1/client.py | 106 ----- .../api/legacy/score_v1/types/__init__.py | 10 - .../score_v1/types/create_score_request.py | 5 - .../score_v1/types/create_score_response.py | 5 - .../score_v1/types/create_score_source.py | 5 - .../api/scores/types/create_score_request.py | 6 + langfuse/langchain/CallbackHandler.py | 1 - langfuse/openai.py | 1 - scripts/patch_generated_score_compat.py | 339 -------------- tests/e2e/test_core_sdk.py | 7 + tests/unit/test_error_parsing.py | 32 +- tests/unit/test_media.py | 2 +- .../unit/test_patch_generated_score_compat.py | 190 -------- tests/unit/test_propagate_attributes.py | 8 +- tests/unit/test_resource_manager.py | 36 +- tests/unit/test_score_consumer.py | 416 ++++++++++++++++++ 24 files changed, 780 insertions(+), 1247 deletions(-) create mode 100644 langfuse/_task_manager/score_consumer.py delete mode 100644 langfuse/_task_manager/score_ingestion_consumer.py delete mode 100644 langfuse/_utils/request.py delete mode 100644 langfuse/api/legacy/score_v1/types/__init__.py delete mode 100644 langfuse/api/legacy/score_v1/types/create_score_request.py delete mode 100644 langfuse/api/legacy/score_v1/types/create_score_response.py delete mode 100644 langfuse/api/legacy/score_v1/types/create_score_source.py delete mode 100644 scripts/patch_generated_score_compat.py delete mode 100644 tests/unit/test_patch_generated_score_compat.py create mode 100644 tests/unit/test_score_consumer.py diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index d8349f802..e7be8214b 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -9,7 +9,7 @@ import re import urllib.parse import uuid -from datetime import datetime +from datetime import datetime, timezone from hashlib import sha256 from time import time_ns from typing import ( @@ -97,6 +97,7 @@ AsyncLangfuseAPI, CreateChatPromptRequest, CreateChatPromptType, + CreateScoreRequest, CreateTextPromptRequest, Dataset, DatasetItem, @@ -108,8 +109,6 @@ NotFoundError, Prompt_Chat, Prompt_Text, - ScoreBody, - TraceBody, ) from langfuse.batch_evaluation import ( BatchEvaluationResult, @@ -1940,28 +1939,34 @@ def create_score( score_id = score_id or self._create_observation_id() + # The API rejects timestamps without a UTC offset; naive values are UTC. + if timestamp is not None and timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + try: - new_body = ScoreBody( + request = CreateScoreRequest( id=score_id, - sessionId=session_id, - datasetRunId=dataset_run_id, - traceId=trace_id, - observationId=observation_id, + session_id=session_id, + dataset_run_id=dataset_run_id, + trace_id=trace_id, + observation_id=observation_id, name=name, value=value, - dataType=data_type, # type: ignore + data_type=data_type, # type: ignore comment=comment, - configId=config_id, + config_id=config_id, environment=environment or self._environment, - metadata=metadata, + timestamp=timestamp or _get_timestamp(), ) - - event = { - "id": self.create_trace_id(), - "type": "score-create", - "timestamp": timestamp or _get_timestamp(), - "body": new_body, + # Fern's dict() keeps fields explicitly set to None, so drop them here. + score = { + key: value + for key, value in request.dict(by_alias=True).items() + if value is not None } + # The API accepts any JSON value as metadata, the generated model only dicts. + if metadata is not None: + score["metadata"] = metadata if self._resources is not None: # Force the score to be in sample if it was for a legacy trace ID, i.e. non-32 hexchar @@ -1970,7 +1975,7 @@ def create_score( ) self._resources.add_score_task( - event, + score, force_sample=force_sample, ) @@ -1983,42 +1988,6 @@ def create_score( e, ) - def _create_trace_tags_via_ingestion( - self, - *, - trace_id: str, - tags: List[str], - ) -> None: - """Private helper to enqueue trace tag updates via ingestion API events.""" - if not self._tracing_enabled: - return - - if len(tags) == 0: - return - - try: - new_body = TraceBody( - id=trace_id, - tags=tags, - ) - - event = { - "id": self.create_trace_id(), - "type": "trace-create", - "timestamp": _get_timestamp(), - "body": new_body, - } - - if self._resources is not None: - self._resources.add_trace_task(event) - except Exception as e: - langfuse_logger.exception( - "Error updating trace tags: Failed to process trace update event for " - "trace_id=%s. Error: %s", - trace_id, - e, - ) - @overload def score_current_span( self, diff --git a/langfuse/_client/resource_manager.py b/langfuse/_client/resource_manager.py index e1e091d31..b5e104812 100644 --- a/langfuse/_client/resource_manager.py +++ b/langfuse/_client/resource_manager.py @@ -8,7 +8,7 @@ - Thread-safe OpenTelemetry tracer with Langfuse-specific span processors and sampling - Configurable batch processing of spans and scores with intelligent flushing behavior - Asynchronous background media upload processing with dedicated worker threads -- Concurrent score ingestion with batching and retry mechanisms +- Background score delivery with batching and retry mechanisms - Automatic project ID discovery and caching - Graceful shutdown handling with proper resource cleanup - Fault tolerance with detailed error logging and recovery mechanisms @@ -43,10 +43,9 @@ from langfuse._client.span_processor import LangfuseSpanProcessor from langfuse._task_manager.media_manager import MediaManager from langfuse._task_manager.media_upload_consumer import MediaUploadConsumer -from langfuse._task_manager.score_ingestion_consumer import ScoreIngestionConsumer +from langfuse._task_manager.score_consumer import ScoreConsumer from langfuse._utils.environment import get_common_release_envs from langfuse._utils.prompt_cache import PromptCache -from langfuse._utils.request import LangfuseClient from langfuse.api import AsyncLangfuseAPI, LangfuseAPI from langfuse.logger import langfuse_logger from langfuse.types import MaskFunction, MaskOtelSpansFunction @@ -60,14 +59,14 @@ class LangfuseResourceManager: This class implements a thread-safe singleton pattern keyed by the public API key, ensuring that only one tracer instance exists per API key combination. It manages the lifecycle of the OpenTelemetry tracer provider, span processors, and resource - attributes, as well as background threads for media uploads and score ingestion. + attributes, as well as background threads for media uploads and score delivery. The tracer is responsible for: 1. Setting up the OpenTelemetry tracer with appropriate sampling and configuration 2. Managing the span processor for exporting spans to the Langfuse API 3. Creating and managing Langfuse API clients (both synchronous and asynchronous) 4. Handling background media upload processing via dedicated worker threads - 5. Processing and batching score ingestion events with configurable flush settings + 5. Batching scores for POST /api/public/scores with configurable flush settings 6. Retrieving and caching project information for URL generation and media handling 7. Coordinating graceful shutdown of all background processes with proper resource cleanup @@ -85,7 +84,7 @@ class LangfuseResourceManager: _otel_tracer: Tracer _media_manager: MediaManager _media_upload_consumers: List[MediaUploadConsumer] - _ingestion_consumers: List[ScoreIngestionConsumer] + _score_consumers: List[ScoreConsumer] @classmethod def get_singleton_httpx_client(cls) -> Optional[httpx.Client]: @@ -281,7 +280,7 @@ def _initialize_instance( # Register fork handler to reinitialize consumer threads in child process. # When using Gunicorn with --preload, os.fork() copies memory but not threads # (POSIX.1: https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html). - # Without this, media upload and score ingestion threads are lost after fork, + # Without this, media upload and score threads are lost after fork, # causing silent data loss. # # Note: LangfuseSpanProcessor (BatchSpanProcessor) already handles fork-safety @@ -364,17 +363,9 @@ def _init_api_clients(self) -> None: x_langfuse_public_key=self.public_key, timeout=self.timeout, ) - self._score_ingestion_client = LangfuseClient( - public_key=self.public_key, - secret_key=self.secret_key, - base_url=self.base_url, - version=langfuse_version, - timeout=self.timeout or 20, - session=self.httpx_client, - ) def _init_consumer_threads(self) -> None: - """Initialize media upload and score ingestion consumer threads.""" + """Initialize media upload and score consumer threads.""" if self._media_upload_enabled: for i in range(self._media_upload_thread_count): media_upload_consumer = MediaUploadConsumer( @@ -384,21 +375,19 @@ def _init_consumer_threads(self) -> None: media_upload_consumer.start() self._media_upload_consumers.append(media_upload_consumer) - # Score ingestion - self._score_ingestion_queue: Queue[Any] = Queue(100_000) - self._ingestion_consumers = [] + self._score_queue: Queue[Any] = Queue(100_000) + self._score_consumers = [] - ingestion_consumer = ScoreIngestionConsumer( - ingestion_queue=self._score_ingestion_queue, + score_consumer = ScoreConsumer( + score_queue=self._score_queue, identifier=0, - client=self._score_ingestion_client, + api_client=self.api, flush_at=self.flush_at, flush_interval=self.flush_interval, max_retries=3, - public_key=self.public_key, ) - ingestion_consumer.start() - self._ingestion_consumers.append(ingestion_consumer) + score_consumer.start() + self._score_consumers.append(score_consumer) def _at_fork_reinit(self) -> None: """Reinitialize consumer threads after fork in child process. @@ -407,7 +396,7 @@ def _at_fork_reinit(self) -> None: Necessary for Gunicorn --preload deployments where os.fork() is used: threads are not copied to child processes (POSIX standard), so without reinitialization, the child process has no consumer threads and all - media upload and score ingestion events are silently lost. + media uploads and scores are silently lost. Note: LangfuseSpanProcessor (BatchSpanProcessor) handles span export fork-safety separately via its own os.register_at_fork handler. @@ -470,7 +459,7 @@ def _at_fork_reinit(self) -> None: except Exception as e: langfuse_logger.error( "[PID %s] Failed to reinitialize consumer threads after fork: %s. Media " - "upload, score ingestion, and prompt cache refresh will be unavailable in " + "upload, score delivery, and prompt cache refresh will be unavailable in " "this worker.", os.getpid(), e, @@ -490,84 +479,60 @@ def reset(cls) -> None: cls._instances.clear() - def add_score_task(self, event: dict, *, force_sample: bool = False) -> None: + def add_score_task(self, score: dict, *, force_sample: bool = False) -> None: + """Enqueue a score for POST /api/public/scores. + + Args: + score: Request body of a single score, keyed by API field names + (for example ``traceId``). + force_sample: Enqueue the score even if its trace is sampled out. + """ try: - # Sample scores with the same sampler that is used for tracing - tracer_provider = cast(TracerProvider, otel_trace_api.get_tracer_provider()) + trace_id = score.get("traceId") + tracer_provider = ( + self.tracer_provider or otel_trace_api.get_tracer_provider() + ) + sampler = getattr(tracer_provider, "sampler", None) should_sample = ( force_sample - or isinstance( - tracer_provider, otel_trace_api.ProxyTracerProvider - ) # default to in-sample if otel sampler is not available - or ( - ( - tracer_provider.sampler.should_sample( - parent_context=None, - trace_id=int(event["body"].trace_id, 16), - name="score", - ).decision - == Decision.RECORD_AND_SAMPLE - if hasattr(event["body"], "trace_id") - else True - ) - if event["body"].trace_id - is not None # do not sample out session / dataset run scores - else True - ) + # do not sample out session / dataset run scores + or trace_id is None + # Only a trace-id ratio sampler gives the trace's actual decision + # from the trace id alone. Parent-based, random or attribute-based + # samplers may have recorded the trace, so keep its scores. + or not isinstance(sampler, TraceIdRatioBased) + or sampler.should_sample( + parent_context=None, + trace_id=int(trace_id, 16), + name="score", + ).decision + == Decision.RECORD_AND_SAMPLE ) if should_sample: langfuse_logger.debug( - "Score: Enqueuing event type=%s for trace_id=%s name=%s value=%s", - event["type"], - event["body"].trace_id, - event["body"].name, - event["body"].value, + "Score: Enqueuing score for trace_id=%s name=%s value=%s", + trace_id, + score.get("name"), + score.get("value"), ) - self._score_ingestion_queue.put(event, block=False) + self._score_queue.put(score, block=False) except Full: langfuse_logger.warning( - "System overload: Score ingestion queue has reached capacity (100,000 items). Score will be dropped. Consider increasing flush frequency or decreasing event volume." + "System overload: Score queue has reached capacity (100,000 items). Score will be dropped. Consider increasing flush frequency or decreasing event volume." ) return except Exception as e: langfuse_logger.error( - "Unexpected error: Failed to process score event. The score will be " + "Unexpected error: Failed to process score. The score will be " "dropped. Error details: %s", e, ) return - def add_trace_task( - self, - event: dict, - ) -> None: - try: - langfuse_logger.debug( - "Trace: Enqueuing event type=%s for trace_id=%s", - event["type"], - event["body"].id, - ) - self._score_ingestion_queue.put(event, block=False) - - except Full: - langfuse_logger.warning( - "System overload: Trace ingestion queue has reached capacity (100,000 items). Trace update will be dropped. Consider increasing flush frequency or decreasing event volume." - ) - - return - except Exception as e: - langfuse_logger.error( - "Unexpected error: Failed to process trace event. The trace update will " - "be dropped. Error details: %s", - e, - ) - - return - @property def tracer(self) -> Optional[Tracer]: return self._otel_tracer @@ -603,22 +568,22 @@ def _stop_and_join_consumer_threads(self) -> None: ) langfuse_logger.debug( - "Shutdown: Waiting for %s score ingestion thread(s) to complete processing", - len(self._ingestion_consumers), + "Shutdown: Waiting for %s score thread(s) to complete processing", + len(self._score_consumers), ) - for score_ingestion_consumer in self._ingestion_consumers: - score_ingestion_consumer.pause() + for score_consumer in self._score_consumers: + score_consumer.pause() - for score_ingestion_consumer in self._ingestion_consumers: + for score_consumer in self._score_consumers: try: - score_ingestion_consumer.join() + score_consumer.join() except RuntimeError: # consumer thread has not started pass langfuse_logger.debug( - "Shutdown: Score ingestion thread #%s successfully terminated", - score_ingestion_consumer._identifier, + "Shutdown: Score thread #%s successfully terminated", + score_consumer._identifier, ) def flush(self) -> None: @@ -628,8 +593,8 @@ def flush(self) -> None: self.tracer_provider.force_flush() langfuse_logger.debug("Successfully flushed OTEL tracer provider") - self._score_ingestion_queue.join() - langfuse_logger.debug("Successfully flushed score ingestion queue") + self._score_queue.join() + langfuse_logger.debug("Successfully flushed score queue") self._media_upload_queue.join() langfuse_logger.debug("Successfully flushed media upload queue") diff --git a/langfuse/_task_manager/score_consumer.py b/langfuse/_task_manager/score_consumer.py new file mode 100644 index 000000000..cdeb81404 --- /dev/null +++ b/langfuse/_task_manager/score_consumer.py @@ -0,0 +1,245 @@ +import json +import os +import threading +import time +from queue import Empty, Full, Queue +from typing import Any, Dict, List, Optional, cast + +import backoff +import httpx + +from langfuse._utils.parse_error import handle_fern_exception +from langfuse._utils.serializer import EventSerializer +from langfuse.api import LangfuseAPI +from langfuse.api.core import ApiError +from langfuse.api.scores.types.create_score_batch_results import ( + CreateScoreBatchResults, +) +from langfuse.api.scores.types.create_scores_request import CreateScoresRequest +from langfuse.logger import langfuse_logger as logger + +# POST /api/public/scores uses the default Next.js body limit of 1 MiB. +_SCORES_BODY_LIMIT_BYTES = 1_000_000 +_configured_batch_size_bytes = int( + os.environ.get("LANGFUSE_MAX_BATCH_SIZE_BYTES", _SCORES_BODY_LIMIT_BYTES) +) +if _configured_batch_size_bytes > _SCORES_BODY_LIMIT_BYTES: + logger.warning( + "Configuration: LANGFUSE_MAX_BATCH_SIZE_BYTES=%s exceeds the %s byte body " + "limit of POST /api/public/scores; score batches are capped at %s bytes.", + _configured_batch_size_bytes, + _SCORES_BODY_LIMIT_BYTES, + _SCORES_BODY_LIMIT_BYTES, + ) +MAX_BATCH_SIZE_BYTES = min(_configured_batch_size_bytes, _SCORES_BODY_LIMIT_BYTES) +_SHUTDOWN_SENTINEL = object() + + +def _is_retryable(error: Exception) -> bool: + if isinstance(error, httpx.TransportError): + return True + + if isinstance(error, ApiError): + status = error.status_code + return status is None or status >= 500 or status == 429 + + return False + + +class ScoreConsumer(threading.Thread): + """Send queued scores to POST /api/public/scores in batches.""" + + def __init__( + self, + *, + score_queue: Queue, + identifier: int, + api_client: LangfuseAPI, + flush_at: Optional[int] = None, + flush_interval: Optional[float] = None, + max_retries: Optional[int] = None, + ): + """Create a consumer thread.""" + super().__init__() + # It's important to set running in the constructor: if we are asked to + # pause immediately after construction, we might set running to True in + # run() *after* we set it to False in pause... and keep running + # forever. + self.running = True + # Make consumer a daemon thread so that it doesn't block program exit + self.daemon = True + self._score_queue = score_queue + self._identifier = identifier + self._api_client = api_client + self._flush_at = flush_at or 15 + self._flush_interval = flush_interval or 1 + self._max_retries = max_retries or 3 + # A score taken from the queue that did not fit into the previous batch. + # It has not been acknowledged yet and starts the next batch. + self._carry_over: Optional[Dict[str, Any]] = None + + def _next(self) -> List[Dict[str, Any]]: + """Return the next batch of JSON-ready scores to upload.""" + scores: List[Dict[str, Any]] = [] + total_size = 2 # enclosing brackets of the JSON list + + if self._carry_over is not None: + scores.append(self._carry_over) + total_size += self._get_item_size(self._carry_over) + self._carry_over = None + + start_time = time.monotonic() + + while len(scores) < self._flush_at: + elapsed = time.monotonic() - start_time + if elapsed >= self._flush_interval: + break + try: + score = self._score_queue.get( + block=True, timeout=self._flush_interval - elapsed + ) + + if score is _SHUTDOWN_SENTINEL: + self._score_queue.task_done() + break + + try: + serialized = json.loads(json.dumps(score, cls=EventSerializer)) + except Exception as e: + logger.error( + "Data error: Failed to serialize score. Score will be " + "dropped. Error: %s", + e, + ) + self._score_queue.task_done() + + continue + + item_size = self._get_item_size(serialized) + + if item_size + 2 > MAX_BATCH_SIZE_BYTES: + logger.error( + "Data error: Score name=%s is %s bytes, which exceeds the " + "request limit of %s bytes. Score will be dropped.", + serialized.get("name"), + item_size, + MAX_BATCH_SIZE_BYTES, + ) + self._score_queue.task_done() + + continue + + # +1 for the separating comma + if scores and total_size + item_size + 1 > MAX_BATCH_SIZE_BYTES: + logger.debug( + "Batch management: Reached maximum batch size limit (%s bytes). " + "Processing %s scores now.", + total_size, + len(scores), + ) + self._carry_over = serialized + break + + scores.append(serialized) + total_size += item_size + (1 if len(scores) > 1 else 0) + + except Empty: + break + + except Exception as e: + logger.warning( + "Data processing error: Failed to process score in consumer thread " + "#%s. Score will be dropped. Error: %s", + self._identifier, + str(e), + exc_info=True, + ) + self._score_queue.task_done() + + return scores + + def _get_item_size(self, item: Any) -> int: + """Return the size of the item in bytes.""" + return len(json.dumps(item).encode()) + + def run(self) -> None: + """Run the consumer.""" + logger.debug( + "Startup: Score consumer thread #%s started with batch size %s " + "and interval %ss", + self._identifier, + self._flush_at, + self._flush_interval, + ) + while self.running: + self.upload() + + while self._carry_over is not None: + self.upload() + + def upload(self) -> None: + """Upload the next batch of scores.""" + batch = self._next() + if len(batch) == 0: + return + + try: + self._upload_batch(batch) + except ApiError as e: + handle_fern_exception(e) + except Exception as e: + logger.error( + "API error: Failed to send %s scores to Langfuse. Scores will be " + "dropped. Error: %s", + len(batch), + e, + ) + finally: + # mark items as acknowledged from queue + for _ in batch: + self._score_queue.task_done() + + def pause(self) -> None: + """Pause the consumer.""" + self.running = False + try: + self._score_queue.put(_SHUTDOWN_SENTINEL, block=False) + except Full: + # If the queue is full, the consumer will wake up naturally while + # draining items, so a dedicated shutdown signal is not required. + pass + + def _upload_batch(self, batch: List[Dict[str, Any]]) -> None: + logger.debug("API: Sending batch of %s scores to Langfuse API", len(batch)) + + @backoff.on_exception( + backoff.expo, + Exception, + max_tries=self._max_retries, + giveup=lambda e: not _is_retryable(e), + logger=None, + ) + def send_with_backoff() -> Any: + # The items are JSON-ready dicts with API field names. Unlike the + # generated request model, the API accepts any JSON value as score + # metadata, so the dicts are sent as they are. + return self._api_client.scores.create( + request=cast(CreateScoresRequest, batch), + # Retries are handled here so that transport errors are retried too. + request_options={"max_retries": 0}, + ) + + response = send_with_backoff() + + if isinstance(response, CreateScoreBatchResults): + logger.error( + "API error: Langfuse accepted %s and rejected %s of %s scores. " + "Rejected scores are not retried. Errors: %s", + response.accepted, + response.rejected, + len(batch), + "; ".join(error.message for error in response.errors), + ) + return + + logger.debug("API: Successfully sent %s scores to Langfuse API", len(batch)) diff --git a/langfuse/_task_manager/score_ingestion_consumer.py b/langfuse/_task_manager/score_ingestion_consumer.py deleted file mode 100644 index 1dce00f80..000000000 --- a/langfuse/_task_manager/score_ingestion_consumer.py +++ /dev/null @@ -1,198 +0,0 @@ -import json -import os -import threading -import time -from queue import Empty, Full, Queue -from typing import Any, List, Optional - -import backoff -from pydantic import BaseModel - -from langfuse._utils.parse_error import handle_exception -from langfuse._utils.request import APIError, LangfuseClient -from langfuse._utils.serializer import EventSerializer -from langfuse.logger import langfuse_logger as logger - -from .._version import __version__ as langfuse_version - -MAX_EVENT_SIZE_BYTES = int(os.environ.get("LANGFUSE_MAX_EVENT_SIZE_BYTES", 1_000_000)) -MAX_BATCH_SIZE_BYTES = int(os.environ.get("LANGFUSE_MAX_BATCH_SIZE_BYTES", 2_500_000)) -_SHUTDOWN_SENTINEL = object() - - -class ScoreIngestionMetadata(BaseModel): - batch_size: int - sdk_name: str - sdk_version: str - public_key: str - - -class ScoreIngestionConsumer(threading.Thread): - def __init__( - self, - *, - ingestion_queue: Queue, - identifier: int, - client: LangfuseClient, - public_key: str, - flush_at: Optional[int] = None, - flush_interval: Optional[float] = None, - max_retries: Optional[int] = None, - ): - """Create a consumer thread.""" - super().__init__() - # It's important to set running in the constructor: if we are asked to - # pause immediately after construction, we might set running to True in - # run() *after* we set it to False in pause... and keep running - # forever. - self.running = True - # Make consumer a daemon thread so that it doesn't block program exit - self.daemon = True - self._ingestion_queue = ingestion_queue - self._identifier = identifier - self._client = client - self._flush_at = flush_at or 15 - self._flush_interval = flush_interval or 1 - self._max_retries = max_retries or 3 - self._public_key = public_key - - def _next(self) -> list: - """Return the next batch of items to upload.""" - events: list = [] - - start_time = time.monotonic() - total_size = 0 - - while len(events) < self._flush_at: - elapsed = time.monotonic() - start_time - if elapsed >= self._flush_interval: - break - try: - event = self._ingestion_queue.get( - block=True, timeout=self._flush_interval - elapsed - ) - - if event is _SHUTDOWN_SENTINEL: - self._ingestion_queue.task_done() - break - - # convert pydantic models to dicts - if "body" in event and isinstance(event["body"], BaseModel): - event["body"] = event["body"].model_dump(exclude_none=True) - - item_size = self._get_item_size(event) - - # check for serialization errors - try: - json.dumps(event, cls=EventSerializer) - except Exception as e: - logger.error( - "Data error: Failed to serialize score object for ingestion. Score will " - "be dropped. Error: %s", - e, - ) - self._ingestion_queue.task_done() - - continue - - events.append(event) - - total_size += item_size - if total_size >= MAX_BATCH_SIZE_BYTES: - logger.debug( - "Batch management: Reached maximum batch size limit (%s bytes). " - "Processing %s events now.", - total_size, - len(events), - ) - break - - except Empty: - break - - except Exception as e: - logger.warning( - "Data processing error: Failed to process score event in consumer thread " - "#%s. Event will be dropped. Error: %s", - self._identifier, - str(e), - exc_info=True, - ) - self._ingestion_queue.task_done() - - return events - - def _get_item_size(self, item: Any) -> int: - """Return the size of the item in bytes.""" - return len(json.dumps(item, cls=EventSerializer).encode()) - - def run(self) -> None: - """Run the consumer.""" - logger.debug( - "Startup: Score ingestion consumer thread #%s started with batch size %s " - "and interval %ss", - self._identifier, - self._flush_at, - self._flush_interval, - ) - while self.running: - self.upload() - - def upload(self) -> None: - """Upload the next batch of items, return whether successful.""" - batch = self._next() - if len(batch) == 0: - return - - try: - self._upload_batch(batch) - except Exception as e: - handle_exception(e) - finally: - # mark items as acknowledged from queue - for _ in batch: - self._ingestion_queue.task_done() - - def pause(self) -> None: - """Pause the consumer.""" - self.running = False - try: - self._ingestion_queue.put(_SHUTDOWN_SENTINEL, block=False) - except Full: - # If the queue is full, the consumer will wake up naturally while - # draining items, so a dedicated shutdown signal is not required. - pass - - def _upload_batch(self, batch: List[Any]) -> None: - logger.debug( - "API: Uploading batch of %s score events to Langfuse API", len(batch) - ) - - metadata = ScoreIngestionMetadata( - batch_size=len(batch), - sdk_name="python", - sdk_version=langfuse_version, - public_key=self._public_key, - ).model_dump() - - @backoff.on_exception( - backoff.expo, Exception, max_tries=self._max_retries, logger=None - ) - def execute_task_with_backoff(batch: List[Any]) -> None: - try: - self._client.batch_post(batch=batch, metadata=metadata) - except Exception as e: - if ( - isinstance(e, APIError) - and 400 <= int(e.status) < 500 - and int(e.status) != 429 # retry if rate-limited - ): - return - - raise e - - execute_task_with_backoff(batch) - logger.debug( - "API: Successfully sent %s score events to Langfuse API in batch mode", - len(batch), - ) diff --git a/langfuse/_utils/parse_error.py b/langfuse/_utils/parse_error.py index 2b9a7bd6f..e16a4eb71 100644 --- a/langfuse/_utils/parse_error.py +++ b/langfuse/_utils/parse_error.py @@ -1,12 +1,5 @@ -from typing import Union - -# our own api errors -from langfuse._utils.request import APIError, APIErrors - -# fern api errors from langfuse.api import ( AccessDeniedError, - Error, MethodNotAllowedError, NotFoundError, ServiceUnavailableError, @@ -44,7 +37,7 @@ } -def generate_error_message_fern(error: Error) -> str: +def generate_error_message_fern(error: ApiError) -> str: if isinstance(error, AccessDeniedError): return errorResponseByCode.get(403, defaultErrorResponse) elif isinstance(error, MethodNotAllowedError): @@ -66,34 +59,7 @@ def generate_error_message_fern(error: Error) -> str: return defaultErrorResponse # type: ignore -def handle_fern_exception(exception: Error) -> None: +def handle_fern_exception(exception: ApiError) -> None: logger.debug(exception) error_message = generate_error_message_fern(exception) logger.error(error_message) - - -def generate_error_message(exception: Union[APIError, APIErrors, Exception]) -> str: - if isinstance(exception, APIError): - status_code = ( - int(exception.status) - if isinstance(exception.status, str) - else exception.status - ) - return f"API error occurred: {errorResponseByCode.get(status_code, defaultErrorResponse)}" - elif isinstance(exception, APIErrors): - error_messages = [ - errorResponseByCode.get( - int(error.status) if isinstance(error.status, str) else error.status, - defaultErrorResponse, - ) - for error in exception.errors - ] - return "API errors occurred: " + "\n".join(error_messages) - else: - return defaultErrorResponse - - -def handle_exception(exception: Union[APIError, APIErrors, Exception]) -> None: - logger.debug(exception) - error_message = generate_error_message(exception) - logger.error(error_message) diff --git a/langfuse/_utils/request.py b/langfuse/_utils/request.py deleted file mode 100644 index 402d0b5a7..000000000 --- a/langfuse/_utils/request.py +++ /dev/null @@ -1,136 +0,0 @@ -"""@private""" - -import json -from base64 import b64encode -from typing import Any, List, Union - -import httpx - -from langfuse._utils.serializer import EventSerializer -from langfuse.logger import langfuse_logger as logger - - -class LangfuseClient: - _public_key: str - _secret_key: str - _base_url: str - _version: str - _timeout: int - _session: httpx.Client - - def __init__( - self, - public_key: str, - secret_key: str, - base_url: str, - version: str, - timeout: int, - session: httpx.Client, - ): - self._public_key = public_key - self._secret_key = secret_key - self._base_url = base_url - self._version = version - self._timeout = timeout - self._session = session - - def generate_headers(self) -> dict: - return { - "Authorization": "Basic " - + b64encode( - f"{self._public_key}:{self._secret_key}".encode("utf-8") - ).decode("ascii"), - "Content-Type": "application/json", - "x-langfuse-sdk-name": "python", - "x-langfuse-sdk-version": self._version, - "x-langfuse-public-key": self._public_key, - } - - def batch_post(self, **kwargs: Any) -> httpx.Response: - """Post the `kwargs` to the batch API endpoint for events""" - res = self.post(**kwargs) - - return self._process_response( - res, success_message="data uploaded successfully", return_json=False - ) - - def post(self, **kwargs: Any) -> httpx.Response: - """Post the `kwargs` to the API""" - url = self._remove_trailing_slash(self._base_url) + "/api/public/ingestion" - data = json.dumps(kwargs, cls=EventSerializer) - logger.debug("making request: %s to %s", data, url) - headers = self.generate_headers() - res = self._session.post( - url, content=data, headers=headers, timeout=self._timeout - ) - - if res.status_code == 200: - logger.debug("data uploaded successfully") - - return res - - def _remove_trailing_slash(self, url: str) -> str: - """Removes the trailing slash from a URL""" - if url.endswith("/"): - return url[:-1] - return url - - def _process_response( - self, res: httpx.Response, success_message: str, *, return_json: bool = True - ) -> Union[httpx.Response, Any]: - logger.debug("received response: %s", res.text) - if res.status_code in (200, 201): - logger.debug(success_message) - if return_json: - try: - return res.json() - except json.JSONDecodeError: - raise APIError(res.status_code, "Invalid JSON response received") - else: - return res - elif res.status_code == 207: - try: - payload = res.json() - errors = payload.get("errors", []) - if errors: - raise APIErrors( - [ - APIError( - error.get("status"), - error.get("message", "No message provided"), - error.get("error", "No error details provided"), - ) - for error in errors - ] - ) - else: - return res.json() if return_json else res - except json.JSONDecodeError: - raise APIError(res.status_code, "Invalid JSON response received") - - try: - payload = res.json() - raise APIError(res.status_code, payload) - except (KeyError, ValueError): - raise APIError(res.status_code, res.text) - - -class APIError(Exception): - def __init__(self, status: Union[int, str], message: str, details: Any = None): - self.message = message - self.status = status - self.details = details - - def __str__(self) -> str: - msg = "{0} ({1}): {2}" - return msg.format(self.message, self.status, self.details) - - -class APIErrors(Exception): - def __init__(self, errors: List[APIError]): - self.errors = errors - - def __str__(self) -> str: - errors = ", ".join(str(error) for error in self.errors) - - return f"[Langfuse] {errors}" diff --git a/langfuse/api/legacy/__init__.py b/langfuse/api/legacy/__init__.py index 65afabbc8..68d217597 100644 --- a/langfuse/api/legacy/__init__.py +++ b/langfuse/api/legacy/__init__.py @@ -38,10 +38,3 @@ def __dir__(): __all__ = ["score_v1"] - -# Score-create compatibility aliases (LFE-10397). -from .score_v1 import CreateScoreRequest -from .score_v1 import CreateScoreResponse -from .score_v1 import CreateScoreSource - -__all__ = [*__all__, "CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource"] diff --git a/langfuse/api/legacy/score_v1/__init__.py b/langfuse/api/legacy/score_v1/__init__.py index 8fdfbc4a0..5cde0202d 100644 --- a/langfuse/api/legacy/score_v1/__init__.py +++ b/langfuse/api/legacy/score_v1/__init__.py @@ -2,9 +2,3 @@ # isort: skip_file -# Score-create compatibility aliases (LFE-10397). -from .types import CreateScoreRequest -from .types import CreateScoreResponse -from .types import CreateScoreSource - -__all__ = ["CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource"] diff --git a/langfuse/api/legacy/score_v1/client.py b/langfuse/api/legacy/score_v1/client.py index 208ccaccb..57608d03c 100644 --- a/langfuse/api/legacy/score_v1/client.py +++ b/langfuse/api/legacy/score_v1/client.py @@ -3,16 +3,6 @@ import typing from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...commons.types.create_score_value import CreateScoreValue -from ...commons.types.score_data_type import ScoreDataType -from ...scores.client import ( - AsyncScoresClient as CanonicalAsyncScoresClient, - ScoresClient as CanonicalScoresClient, - OMIT, -) -from ...scores.types.create_score_request import CreateScoreRequest -from ...scores.types.create_score_response import CreateScoreResponse -from ...scores.types.create_score_source import CreateScoreSource from ...core.request_options import RequestOptions from .raw_client import AsyncRawScoreV1Client, RawScoreV1Client @@ -32,54 +22,6 @@ def with_raw_response(self) -> RawScoreV1Client: """ return self._raw_client - def create( - self, - *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: - """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - optional_fields: typing.Dict[str, typing.Any] = { - "id": id, - "trace_id": trace_id, - "session_id": session_id, - "observation_id": observation_id, - "dataset_run_id": dataset_run_id, - "comment": comment, - "metadata": metadata, - "environment": environment, - "queue_id": queue_id, - "data_type": data_type, - "config_id": config_id, - "source": source, - } - request = CreateScoreRequest( - name=name, - value=value, - **{ - key: field - for key, field in optional_fields.items() - if field is not OMIT - }, - ) - response = CanonicalScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create(request=request, request_options=request_options) - return typing.cast(CreateScoreResponse, response) - def delete( self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> None: @@ -133,54 +75,6 @@ def with_raw_response(self) -> AsyncRawScoreV1Client: """ return self._raw_client - async def create( - self, - *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: - """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - optional_fields: typing.Dict[str, typing.Any] = { - "id": id, - "trace_id": trace_id, - "session_id": session_id, - "observation_id": observation_id, - "dataset_run_id": dataset_run_id, - "comment": comment, - "metadata": metadata, - "environment": environment, - "queue_id": queue_id, - "data_type": data_type, - "config_id": config_id, - "source": source, - } - request = CreateScoreRequest( - name=name, - value=value, - **{ - key: field - for key, field in optional_fields.items() - if field is not OMIT - }, - ) - response = await CanonicalAsyncScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create(request=request, request_options=request_options) - return typing.cast(CreateScoreResponse, response) - async def delete( self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> None: diff --git a/langfuse/api/legacy/score_v1/types/__init__.py b/langfuse/api/legacy/score_v1/types/__init__.py deleted file mode 100644 index 75b37a8ed..000000000 --- a/langfuse/api/legacy/score_v1/types/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# This package is generated by Fern and extended for compatibility. - -__all__: list[str] = [] - -# Score-create compatibility aliases (LFE-10397). -from .create_score_request import CreateScoreRequest -from .create_score_response import CreateScoreResponse -from .create_score_source import CreateScoreSource - -__all__ = [*__all__, "CreateScoreRequest", "CreateScoreResponse", "CreateScoreSource"] diff --git a/langfuse/api/legacy/score_v1/types/create_score_request.py b/langfuse/api/legacy/score_v1/types/create_score_request.py deleted file mode 100644 index 1f1007fc6..000000000 --- a/langfuse/api/legacy/score_v1/types/create_score_request.py +++ /dev/null @@ -1,5 +0,0 @@ -# This compatibility alias is applied after Fern generation. - -from ....scores.types.create_score_request import CreateScoreRequest - -__all__ = ["CreateScoreRequest"] diff --git a/langfuse/api/legacy/score_v1/types/create_score_response.py b/langfuse/api/legacy/score_v1/types/create_score_response.py deleted file mode 100644 index 876c50f0b..000000000 --- a/langfuse/api/legacy/score_v1/types/create_score_response.py +++ /dev/null @@ -1,5 +0,0 @@ -# This compatibility alias is applied after Fern generation. - -from ....scores.types.create_score_response import CreateScoreResponse - -__all__ = ["CreateScoreResponse"] diff --git a/langfuse/api/legacy/score_v1/types/create_score_source.py b/langfuse/api/legacy/score_v1/types/create_score_source.py deleted file mode 100644 index 341481d77..000000000 --- a/langfuse/api/legacy/score_v1/types/create_score_source.py +++ /dev/null @@ -1,5 +0,0 @@ -# This compatibility alias is applied after Fern generation. - -from ....scores.types.create_score_source import CreateScoreSource - -__all__ = ["CreateScoreSource"] diff --git a/langfuse/api/scores/types/create_score_request.py b/langfuse/api/scores/types/create_score_request.py index 71c4d0ba3..cb572ccb6 100644 --- a/langfuse/api/scores/types/create_score_request.py +++ b/langfuse/api/scores/types/create_score_request.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing import pydantic @@ -76,6 +77,11 @@ class CreateScoreRequest(UniversalBaseModel): The source of the score. Defaults to API. Set to ANNOTATION to prefill scores (e.g. from an LLM) for a human reviewer to verify in an annotation queue. When source is ANNOTATION, a configId is required unless dataType is CORRECTION. EVAL is reserved for internal evaluator outputs and is not accepted on this endpoint. """ + timestamp: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + The time of the score. Defaults to the time the request is received. + """ + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( extra="allow", frozen=True ) diff --git a/langfuse/langchain/CallbackHandler.py b/langfuse/langchain/CallbackHandler.py index 0da6deb1e..898a6b22c 100644 --- a/langfuse/langchain/CallbackHandler.py +++ b/langfuse/langchain/CallbackHandler.py @@ -1319,7 +1319,6 @@ def on_llm_end( if generation is not None: generation.update( output=extracted_response, - usage=llm_usage, usage_details=llm_usage, input=kwargs.get("inputs"), model=model, diff --git a/langfuse/openai.py b/langfuse/openai.py index d3758ecb5..43e00b54a 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -1398,7 +1398,6 @@ async def _wrap_async( generation.update( model=model, output=completion, - usage=usage, # backward compat for all V2 self hosters usage_details=usage, cost_details=_parse_cost(parsed_response.usage) if hasattr(parsed_response, "usage") diff --git a/scripts/patch_generated_score_compat.py b/scripts/patch_generated_score_compat.py deleted file mode 100644 index b94784745..000000000 --- a/scripts/patch_generated_score_compat.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env python3 -"""Preserve the legacy score-create API after Fern moves it to ``scores``. - -Fern owns ``langfuse/api`` and regenerates it from the main Langfuse repository. -This postprocessor is intentionally narrow and fail-closed: it only patches the -known generated shape where ``scores.create`` exists and -``legacy.score_v1.create`` no longer does. -""" - -from __future__ import annotations - -import argparse -import ast -import re -from pathlib import Path - -CLIENT_IMPORTS = """\ -from ...commons.types.create_score_value import CreateScoreValue -from ...commons.types.score_data_type import ScoreDataType -from ...scores.client import ( - AsyncScoresClient as CanonicalAsyncScoresClient, - ScoresClient as CanonicalScoresClient, - OMIT, -) -from ...scores.types.create_score_request import CreateScoreRequest -from ...scores.types.create_score_response import CreateScoreResponse -from ...scores.types.create_score_source import CreateScoreSource -""" - -# Earlier alias generations, stripped before the current alias is re-applied. -PREVIOUS_CLIENT_IMPORTS = ( - """\ -from ...commons.types.create_score_value import CreateScoreValue -from ...commons.types.score_data_type import ScoreDataType -from ...scores.client import ( - AsyncScoresClient as CanonicalAsyncScoresClient, - ScoresClient as CanonicalScoresClient, - OMIT, -) -from ...scores.types.create_score_response import CreateScoreResponse -from ...scores.types.create_score_source import CreateScoreSource -""", -) - -# A single-score request always returns CreateScoreResponse; the batch members -# of the CreateScoresResponse union only answer list requests. -SYNC_CREATE_METHOD = '''\ - def create( - self, - *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: - """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - optional_fields: typing.Dict[str, typing.Any] = { - "id": id, - "trace_id": trace_id, - "session_id": session_id, - "observation_id": observation_id, - "dataset_run_id": dataset_run_id, - "comment": comment, - "metadata": metadata, - "environment": environment, - "queue_id": queue_id, - "data_type": data_type, - "config_id": config_id, - "source": source, - } - request = CreateScoreRequest( - name=name, - value=value, - **{ - key: field - for key, field in optional_fields.items() - if field is not OMIT - }, - ) - response = CanonicalScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create(request=request, request_options=request_options) - return typing.cast(CreateScoreResponse, response) - -''' - -ASYNC_CREATE_METHOD = '''\ - async def create( - self, - *, - name: str, - value: CreateScoreValue, - id: typing.Optional[str] = OMIT, - trace_id: typing.Optional[str] = OMIT, - session_id: typing.Optional[str] = OMIT, - observation_id: typing.Optional[str] = OMIT, - dataset_run_id: typing.Optional[str] = OMIT, - comment: typing.Optional[str] = OMIT, - metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, - environment: typing.Optional[str] = OMIT, - queue_id: typing.Optional[str] = OMIT, - data_type: typing.Optional[ScoreDataType] = OMIT, - config_id: typing.Optional[str] = OMIT, - source: typing.Optional[CreateScoreSource] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> CreateScoreResponse: - """**Deprecated compatibility alias.** Use ``client.scores.create``.""" - optional_fields: typing.Dict[str, typing.Any] = { - "id": id, - "trace_id": trace_id, - "session_id": session_id, - "observation_id": observation_id, - "dataset_run_id": dataset_run_id, - "comment": comment, - "metadata": metadata, - "environment": environment, - "queue_id": queue_id, - "data_type": data_type, - "config_id": config_id, - "source": source, - } - request = CreateScoreRequest( - name=name, - value=value, - **{ - key: field - for key, field in optional_fields.items() - if field is not OMIT - }, - ) - response = await CanonicalAsyncScoresClient( - client_wrapper=self._raw_client._client_wrapper - ).create(request=request, request_options=request_options) - return typing.cast(CreateScoreResponse, response) - -''' - -TYPE_NAMES = ( - "CreateScoreRequest", - "CreateScoreResponse", - "CreateScoreSource", -) -LEGACY_ALIAS_MARKER = ( - "**Deprecated compatibility alias.** Use ``client.scores.create``." -) - - -def _replace_once(contents: str, marker: str, replacement: str, *, path: Path) -> str: - count = contents.count(marker) - if count != 1: - raise RuntimeError( - f"Expected exactly one compatibility marker in {path}: {marker!r}; " - f"found {count}" - ) - return contents.replace(marker, replacement, 1) - - -def _append_type_exports(path: Path, imports_by_type: dict[str, str]) -> None: - exports_marker = "# Score-create compatibility aliases (LFE-10397)." - contents = path.read_text() - if exports_marker in contents: - return - - imports = "\n".join( - f"from {module_name} import {type_name}" - for type_name, module_name in imports_by_type.items() - ) - names = ", ".join(repr(type_name) for type_name in TYPE_NAMES) - defines_all = any( - ( - isinstance(node, ast.Assign) - and any( - isinstance(target, ast.Name) and target.id == "__all__" - for target in node.targets - ) - ) - or ( - isinstance(node, ast.AnnAssign) - and isinstance(node.target, ast.Name) - and node.target.id == "__all__" - ) - for node in ast.parse(contents).body - ) - exports = ( - f"__all__ = [*__all__, {names}]" if defines_all else f"__all__ = [{names}]" - ) - path.write_text(f"{contents.rstrip()}\n\n{exports_marker}\n{imports}\n{exports}\n") - - -def _create_type_aliases(types_dir: Path) -> None: - types_dir.mkdir(parents=True, exist_ok=True) - for type_name in TYPE_NAMES: - module_name = "".join( - f"_{character.lower()}" if character.isupper() else character - for character in type_name - ).lstrip("_") - alias_path = types_dir / f"{module_name}.py" - alias_path.write_text( - "# This compatibility alias is applied after Fern generation.\n\n" - f"from ....scores.types.{module_name} import {type_name}\n\n" - f'__all__ = ["{type_name}"]\n' - ) - - -def _strip_compat_alias(contents: str, *, path: Path) -> str: - """Remove a previously applied alias so the current template can be re-applied.""" - for imports in (CLIENT_IMPORTS, *PREVIOUS_CLIENT_IMPORTS): - if imports in contents: - contents = contents.replace(imports, "", 1) - break - else: - raise RuntimeError(f"Unrecognized compatibility imports in {path}") - - stripped = re.sub( - r"\n (async )?def create\(.*?(?=\n (async )?def delete\()", - "", - contents, - flags=re.DOTALL, - ) - if LEGACY_ALIAS_MARKER in stripped: - raise RuntimeError(f"Could not remove the compatibility alias from {path}") - return stripped - - -def patch_generated_api(api_root: Path) -> bool: - canonical_client_path = api_root / "scores" / "client.py" - legacy_client_path = api_root / "legacy" / "score_v1" / "client.py" - - canonical_client = canonical_client_path.read_text() - legacy_client = legacy_client_path.read_text() - - canonical_has_create = ( - "\n def create(" in canonical_client - and "\n async def create(" in canonical_client - ) - legacy_has_create = ( - "\n def create(" in legacy_client - and "\n async def create(" in legacy_client - ) - - if legacy_has_create: - if LEGACY_ALIAS_MARKER in legacy_client and canonical_has_create: - if SYNC_CREATE_METHOD in legacy_client and CLIENT_IMPORTS in legacy_client: - return False - legacy_client = _strip_compat_alias(legacy_client, path=legacy_client_path) - legacy_client_path.write_text(legacy_client) - return patch_generated_api(api_root) - if canonical_has_create: - raise RuntimeError( - "Both canonical and legacy generated score clients define create; " - "refusing to introduce a third compatibility surface" - ) - return False - - if not canonical_has_create: - raise RuntimeError( - "Neither generated score client defines both sync and async create methods" - ) - - legacy_client = _replace_once( - legacy_client, - "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n", - "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n" - f"{CLIENT_IMPORTS}", - path=legacy_client_path, - ) - legacy_client = _replace_once( - legacy_client, - "\n def delete(", - f"\n{SYNC_CREATE_METHOD} def delete(", - path=legacy_client_path, - ) - legacy_client = _replace_once( - legacy_client, - "\n async def delete(", - f"\n{ASYNC_CREATE_METHOD} async def delete(", - path=legacy_client_path, - ) - legacy_client_path.write_text(legacy_client) - - legacy_package_path = api_root / "legacy" / "score_v1" / "__init__.py" - legacy_parent_package_path = api_root / "legacy" / "__init__.py" - legacy_types_path = api_root / "legacy" / "score_v1" / "types" / "__init__.py" - _create_type_aliases(legacy_types_path.parent) - - if not legacy_types_path.exists(): - legacy_types_path.write_text( - "# This package is generated by Fern and extended for compatibility.\n\n" - "__all__: list[str] = []\n" - ) - - _append_type_exports( - legacy_types_path, - { - "CreateScoreRequest": ".create_score_request", - "CreateScoreResponse": ".create_score_response", - "CreateScoreSource": ".create_score_source", - }, - ) - _append_type_exports( - legacy_package_path, {type_name: ".types" for type_name in TYPE_NAMES} - ) - _append_type_exports( - legacy_parent_package_path, - {type_name: ".score_v1" for type_name in TYPE_NAMES}, - ) - return True - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--api-root", - type=Path, - default=Path("langfuse/api"), - help="Path to the freshly generated Fern Python package", - ) - args = parser.parse_args() - changed = patch_generated_api(args.api_root) - print( - "Patched legacy score create compatibility alias." - if changed - else "No patch needed." - ) - - -if __name__ == "__main__": - main() diff --git a/tests/e2e/test_core_sdk.py b/tests/e2e/test_core_sdk.py index 6d80f821e..7105f80e7 100644 --- a/tests/e2e/test_core_sdk.py +++ b/tests/e2e/test_core_sdk.py @@ -360,6 +360,13 @@ def test_create_text_score(): ) +@pytest.mark.xfail( + strict=False, + reason=( + "Needs the Langfuse platform release that adds timestamp to POST /scores " + "(langfuse prep-js-v6-py-v5-sdk-majors). Remove this marker once it ships." + ), +) def test_create_score_with_custom_timestamp(): langfuse = Langfuse() diff --git a/tests/unit/test_error_parsing.py b/tests/unit/test_error_parsing.py index 36a7decbf..d71678873 100644 --- a/tests/unit/test_error_parsing.py +++ b/tests/unit/test_error_parsing.py @@ -1,10 +1,6 @@ """@private""" -from langfuse._utils.parse_error import ( - generate_error_message, - generate_error_message_fern, -) -from langfuse._utils.request import APIError, APIErrors +from langfuse._utils.parse_error import generate_error_message_fern from langfuse.api import ( AccessDeniedError, MethodNotAllowedError, @@ -15,32 +11,6 @@ from langfuse.api.core import ApiError -def test_generate_error_message_api_error(): - exception = APIError(message="Test API error", status="500") - expected_message = "API error occurred: Internal server error occurred. For help, please contact support: https://langfuse.com/support" - assert expected_message in generate_error_message(exception) - - -def test_generate_error_message_api_errors(): - errors = [ - APIError(status=400, message="Bad request", details="Invalid input"), - APIError(status=401, message="Unauthorized", details="Invalid credentials"), - ] - exception = APIErrors(errors) - expected_message = ( - "API errors occurred: " - "Bad request. Please check your request for any missing or incorrect parameters. Refer to our API docs: https://api.reference.langfuse.com for details.\n" - "Unauthorized. Please check your public/private host settings. Refer to our installation and setup guide: https://langfuse.com/docs/sdk/typescript/guide for details on SDK configuration." - ) - assert expected_message in generate_error_message(exception) - - -def test_generate_error_message_generic_exception(): - exception = Exception("Generic error") - expected_message = "Unexpected error occurred. Please check your request and contact support: https://langfuse.com/support." - assert generate_error_message(exception) == expected_message - - def test_generate_error_message_access_denied_error(): exception = AccessDeniedError(body={}) expected_message = "Forbidden. Please check your access control settings. Refer to our RBAC docs: https://langfuse.com/docs/rbac for details." diff --git a/tests/unit/test_media.py b/tests/unit/test_media.py index 387eae745..f222df576 100644 --- a/tests/unit/test_media.py +++ b/tests/unit/test_media.py @@ -263,6 +263,7 @@ def test_resolve_media_references_uses_configured_httpx_client(): "https://example.com/test.jpg", timeout=fetch_timeout_seconds ) + def test_init_with_urlsafe_base64_data_uri(): original_bytes = b"\xfb\xff" urlsafe_base64 = base64.urlsafe_b64encode(original_bytes).decode() @@ -274,4 +275,3 @@ def test_init_with_urlsafe_base64_data_uri(): assert media._source == "base64_data_uri" assert media._content_type == "application/octet-stream" assert media._content_bytes == original_bytes - diff --git a/tests/unit/test_patch_generated_score_compat.py b/tests/unit/test_patch_generated_score_compat.py deleted file mode 100644 index fd0bea9bf..000000000 --- a/tests/unit/test_patch_generated_score_compat.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -import typing -from pathlib import Path -from runpy import run_path -from types import SimpleNamespace - - -def _write_generated_fixture(api_root: Path, *, canonical_has_create: bool) -> None: - scores_dir = api_root / "scores" - legacy_dir = api_root / "legacy" / "score_v1" - legacy_types_dir = legacy_dir / "types" - scores_dir.mkdir(parents=True) - legacy_types_dir.mkdir(parents=True) - - canonical_methods = ( - "\n def create(self):\n pass\n" - "\n async def create(self):\n pass\n" - if canonical_has_create - else "" - ) - canonical_body = canonical_methods or " pass\n" - (scores_dir / "client.py").write_text(f"class ScoresClient:{canonical_body}") - (legacy_dir / "client.py").write_text( - "import typing\n" - "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n\n" - "class ScoreV1Client:\n" - " def delete(self):\n" - " pass\n\n" - "class AsyncScoreV1Client:\n" - " async def delete(self):\n" - " pass\n" - ) - (legacy_dir / "__init__.py").write_text( - "# This file was auto-generated by Fern from our API Definition.\n" - ) - (legacy_dir.parent / "__init__.py").write_text("__all__ = []\n") - (legacy_types_dir / "__init__.py").write_text("__all__ = []\n") - - -def _run_postprocessor(api_root: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - "scripts/patch_generated_score_compat.py", - "--api-root", - str(api_root), - ], - check=False, - capture_output=True, - text=True, - ) - - -def test_postprocessor_adds_delegating_methods_and_type_aliases(tmp_path: Path) -> None: - api_root = tmp_path / "api" - _write_generated_fixture(api_root, canonical_has_create=True) - - result = _run_postprocessor(api_root) - - assert result.returncode == 0, result.stderr - client = (api_root / "legacy" / "score_v1" / "client.py").read_text() - assert "def create(" in client - assert "async def create(" in client - assert "CanonicalScoresClient(" in client - assert "CanonicalAsyncScoresClient(" in client - assert "client.scores.create" in client - compile(client, "", "exec") - - response_alias = ( - api_root / "legacy" / "score_v1" / "types" / "create_score_response.py" - ).read_text() - assert ( - "from ....scores.types.create_score_response import CreateScoreResponse" - in response_alias - ) - assert ( - "CreateScoreResponse" - in (api_root / "legacy" / "score_v1" / "__init__.py").read_text() - ) - legacy_package = (api_root / "legacy" / "score_v1" / "__init__.py").read_text() - assert "__all__ = [" in legacy_package - assert "[*__all__" not in legacy_package - assert "CreateScoreResponse" in (api_root / "legacy" / "__init__.py").read_text() - - second_result = _run_postprocessor(api_root) - assert second_result.returncode == 0, second_result.stderr - assert second_result.stdout.strip() == "No patch needed." - - -def test_postprocessor_fails_when_no_generated_create_exists(tmp_path: Path) -> None: - api_root = tmp_path / "api" - _write_generated_fixture(api_root, canonical_has_create=False) - - result = _run_postprocessor(api_root) - - assert result.returncode != 0 - assert "Neither generated score client defines" in result.stderr - - -def test_generated_sync_alias_delegates_to_canonical_client() -> None: - postprocessor = run_path("scripts/patch_generated_score_compat.py") - sync_create_method = postprocessor["SYNC_CREATE_METHOD"] - calls: dict[str, object] = {} - omitted = object() - - class FakeCreateScoreRequest: - def __init__(self, **fields: object): - self.fields = fields - - class FakeCanonicalScoresClient: - def __init__(self, *, client_wrapper: object): - calls["client_wrapper"] = client_wrapper - - def create(self, **kwargs: object) -> str: - calls["kwargs"] = kwargs - return "created" - - namespace = { - "CanonicalScoresClient": FakeCanonicalScoresClient, - "CreateScoreRequest": FakeCreateScoreRequest, - "CreateScoreResponse": str, - "CreateScoreSource": object, - "CreateScoreValue": object, - "OMIT": omitted, - "RequestOptions": object, - "ScoreDataType": object, - "typing": typing, - } - exec( - "class LegacyScoreClient:\n" - " def __init__(self, client_wrapper):\n" - " self._raw_client = SimpleNamespace(" - "_client_wrapper=client_wrapper)\n" - f"{sync_create_method}", - {**namespace, "SimpleNamespace": SimpleNamespace}, - namespace, - ) - - client_wrapper = object() - legacy_client = namespace["LegacyScoreClient"](client_wrapper) - response = legacy_client.create(name="quality", value=0.9, trace_id="trace-id") - - assert response == "created" - assert calls["client_wrapper"] is client_wrapper - kwargs = typing.cast(dict, calls["kwargs"]) - assert set(kwargs) == {"request", "request_options"} - assert kwargs["request_options"] is None - # Arguments that were not passed are left out of the request body. - assert kwargs["request"].fields == { - "name": "quality", - "value": 0.9, - "trace_id": "trace-id", - } - - -def test_postprocessor_upgrades_a_previously_applied_alias(tmp_path: Path) -> None: - postprocessor = run_path("scripts/patch_generated_score_compat.py") - api_root = tmp_path / "api" - _write_generated_fixture(api_root, canonical_has_create=True) - assert _run_postprocessor(api_root).returncode == 0 - - client_path = api_root / "legacy" / "score_v1" / "client.py" - client = client_path.read_text() - previous_imports = postprocessor["PREVIOUS_CLIENT_IMPORTS"][0] - old_method = ( - " def create(self, *, name, value):\n" - ' """**Deprecated compatibility alias.** Use ``client.scores.create``."""\n' - " return CanonicalScoresClient().create(name=name, value=value)\n\n" - ) - old_async_method = old_method.replace("def create", "async def create") - client = client.replace(postprocessor["CLIENT_IMPORTS"], previous_imports) - client = client.replace(postprocessor["SYNC_CREATE_METHOD"], old_method) - client = client.replace(postprocessor["ASYNC_CREATE_METHOD"], old_async_method) - client_path.write_text(client) - - result = _run_postprocessor(api_root) - - assert result.returncode == 0, result.stderr - upgraded = client_path.read_text() - assert upgraded.count("def create(") == 2 - assert "create(request=request" in upgraded - assert "create(name=name, value=value)" not in upgraded - assert "from ...scores.types.create_score_request import CreateScoreRequest" in ( - upgraded - ) - compile(upgraded, "", "exec") - assert _run_postprocessor(api_root).stdout.strip() == "No patch needed." diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 89d8fb3c2..c1b5947fe 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -2033,7 +2033,7 @@ class TestPropagateAttributesEnvironment(TestPropagateAttributesBase): """Tests for first-class Langfuse environment propagation.""" def _capture_score_events(self, monkeypatch, langfuse_client): - """Capture score ingestion events before they reach the background queue.""" + """Capture scores before they reach the background queue.""" score_events = [] assert langfuse_client._resources is not None @@ -2150,7 +2150,7 @@ def test_span_score_uses_propagated_environment(self, monkeypatch, langfuse_clie span.score(name="quality", value=1.0, data_type="NUMERIC") assert len(score_events) == 1 - assert score_events[0]["body"].environment == "dev" + assert score_events[0]["environment"] == "dev" def test_span_score_trace_uses_propagated_environment( self, monkeypatch, langfuse_client @@ -2166,7 +2166,7 @@ def test_span_score_trace_uses_propagated_environment( span.score_trace(name="overall-quality", value=0.95) assert len(score_events) == 1 - assert score_events[0]["body"].environment == "staging" + assert score_events[0]["environment"] == "staging" def test_current_score_helpers_use_propagated_environment( self, monkeypatch, langfuse_client @@ -2184,7 +2184,7 @@ def test_current_score_helpers_use_propagated_environment( name="trace-quality", value=0.8, data_type="NUMERIC" ) - assert [event["body"].environment for event in score_events] == ["qa", "qa"] + assert [score["environment"] for score in score_events] == ["qa", "qa"] def test_environment_exactly_40_chars_is_accepted( self, langfuse_client, memory_exporter diff --git a/tests/unit/test_resource_manager.py b/tests/unit/test_resource_manager.py index f48e61555..519b21e71 100644 --- a/tests/unit/test_resource_manager.py +++ b/tests/unit/test_resource_manager.py @@ -14,7 +14,7 @@ from langfuse._client.resource_manager import LangfuseResourceManager from langfuse._task_manager.media_manager import MediaManager from langfuse._task_manager.media_upload_consumer import MediaUploadConsumer -from langfuse._task_manager.score_ingestion_consumer import ScoreIngestionConsumer +from langfuse._task_manager.score_consumer import ScoreConsumer from langfuse.types import MaskOtelSpansResult @@ -141,12 +141,11 @@ def should_export_b(span): client_b.shutdown() -def test_score_ingestion_consumer_pause_wakes_blocked_thread(): - consumer = ScoreIngestionConsumer( - ingestion_queue=Queue(), +def test_score_consumer_pause_wakes_blocked_thread(): + consumer = ScoreConsumer( + score_queue=Queue(), identifier=0, - client=Mock(), - public_key="pk-test", + api_client=Mock(), flush_interval=30, ) @@ -188,20 +187,20 @@ def test_at_fork_reinit_creates_new_queues_and_consumers(monkeypatch): rm = client._resources assert rm is not None - old_score_queue = rm._score_ingestion_queue + old_score_queue = rm._score_queue old_media_queue = rm._media_upload_queue - old_ingestion_consumers = list(rm._ingestion_consumers) + old_score_consumers = list(rm._score_consumers) rm._at_fork_reinit() - assert rm._score_ingestion_queue is not old_score_queue + assert rm._score_queue is not old_score_queue assert rm._media_upload_queue is not old_media_queue - assert len(rm._ingestion_consumers) == 1 - assert rm._ingestion_consumers[0].is_alive() + assert len(rm._score_consumers) == 1 + assert rm._score_consumers[0].is_alive() # In a real fork, old threads don't exist in the child process. # In this unit test they do — stop them explicitly to avoid leaking threads. - for consumer in old_ingestion_consumers: + for consumer in old_score_consumers: consumer.pause() consumer.join(timeout=1.0) @@ -223,13 +222,13 @@ def test_at_fork_reinit_skips_when_shutdown(monkeypatch): rm = client._resources assert rm is not None - old_score_queue = rm._score_ingestion_queue + old_score_queue = rm._score_queue old_lock = LangfuseResourceManager._lock rm._shutdown = True rm._at_fork_reinit() - assert rm._score_ingestion_queue is old_score_queue # queue must not be replaced + assert rm._score_queue is old_score_queue # queue must not be replaced # The class-level lock is shared state the child still needs (e.g. to create # a new client), so it must be replaced even when this instance is shut down. assert LangfuseResourceManager._lock is not old_lock @@ -331,7 +330,6 @@ def test_at_fork_reinit_recreates_httpx_client_by_default(monkeypatch): old_httpx_client = rm.httpx_client old_api = rm.api old_async_api = rm.async_api - old_score_ingestion_client = rm._score_ingestion_client assert client.api is old_api assert client.async_api is old_async_api @@ -344,7 +342,7 @@ def test_at_fork_reinit_recreates_httpx_client_by_default(monkeypatch): assert client.api is not old_api assert client.async_api is rm.async_api assert client.async_api is not old_async_api - assert rm._score_ingestion_client is not old_score_ingestion_client + assert rm._score_consumers[0]._api_client is rm.api client.shutdown() @@ -377,7 +375,7 @@ def test_at_fork_reinit_preserves_custom_httpx_client(monkeypatch): assert rm.httpx_client is custom_client assert rm.api is not None assert rm.async_api is not None - assert rm._score_ingestion_client is not None + assert rm._score_consumers[0]._api_client is rm.api custom_client.close() client.shutdown() @@ -433,7 +431,7 @@ def test_create_score_with_invalid_input_logs_error_without_enqueueing( client.create_score(name="invalid", value=object(), trace_id="a" * 32) assert enqueued == [] - assert rm._score_ingestion_queue.empty() + assert rm._score_queue.empty() error_records = [r for r in caplog.records if r.levelno == logging.ERROR] assert len(error_records) == 1 assert "Error creating score" in error_records[0].getMessage() @@ -460,7 +458,7 @@ def signal_shutdown(self, *, count): fake_resource_manager = SimpleNamespace( _media_upload_consumers=[FakeConsumer(0), FakeConsumer(1)], - _ingestion_consumers=[], + _score_consumers=[], _media_manager=FakeMediaManager(), ) diff --git a/tests/unit/test_score_consumer.py b/tests/unit/test_score_consumer.py new file mode 100644 index 000000000..a29adc535 --- /dev/null +++ b/tests/unit/test_score_consumer.py @@ -0,0 +1,416 @@ +"""Delivery of queued scores to POST /api/public/scores.""" + +import json +import logging +import threading +import time +from datetime import datetime, timezone +from queue import Queue +from typing import Any, Callable, Dict, List, Sequence + +import httpx +import pytest +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF, ParentBased, TraceIdRatioBased + +from langfuse import Langfuse +from langfuse._client.resource_manager import LangfuseResourceManager +from langfuse._task_manager import score_consumer as score_consumer_module +from langfuse._task_manager.score_consumer import ScoreConsumer +from langfuse.api import LangfuseAPI + +ACCEPTED = (202, {"message": "Accepted"}) + + +class NoOpSpanExporter(SpanExporter): + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + +Responder = Callable[[httpx.Request], httpx.Response] + + +def _responder(*responses: Any) -> Responder: + """Answer requests in order; an exception instance is raised instead.""" + remaining = list(responses) + + def respond(request: httpx.Request) -> httpx.Response: + response = remaining.pop(0) if len(remaining) > 1 else remaining[0] + if isinstance(response, Exception): + raise response + status, body = response + return httpx.Response(status, json=body) + + return respond + + +class Recorder: + def __init__(self, respond: Responder) -> None: + self.requests: List[httpx.Request] = [] + self._respond = respond + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self._respond(request) + + @property + def bodies(self) -> List[Any]: + return [json.loads(request.content) for request in self.requests] + + +def _score(name: str, **fields: Any) -> Dict[str, Any]: + return { + "id": f"id-{name}", + "traceId": "a" * 32, + "name": name, + "value": 1.0, + **fields, + } + + +@pytest.fixture(autouse=True) +def no_backoff_sleep(monkeypatch): + monkeypatch.setattr("backoff._sync.time.sleep", lambda _: None) + + +def _consumer(recorder: Recorder, queue: Queue, **kwargs: Any) -> ScoreConsumer: + api = LangfuseAPI( + base_url="http://langfuse.test", + username="pk", + password="sk", + httpx_client=httpx.Client(transport=httpx.MockTransport(recorder)), + ) + kwargs.setdefault("flush_interval", 0.05) + return ScoreConsumer(score_queue=queue, identifier=0, api_client=api, **kwargs) + + +def _drain(consumer: ScoreConsumer, queue: Queue) -> None: + while queue.unfinished_tasks: + consumer.upload() + + +def test_sends_queued_scores_as_one_list(caplog): + recorder = Recorder(_responder(ACCEPTED)) + queue: Queue = Queue() + for name in ("a", "b", "c"): + queue.put(_score(name)) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert [r.url.path for r in recorder.requests] == ["/api/public/scores"] + assert [score["name"] for score in recorder.bodies[0]] == ["a", "b", "c"] + assert caplog.records == [] + + +def test_splits_batches_at_flush_at(): + recorder = Recorder(_responder(ACCEPTED)) + queue: Queue = Queue() + for name in ("a", "b", "c"): + queue.put(_score(name)) + + _drain(_consumer(recorder, queue, flush_at=2), queue) + + assert [[s["name"] for s in body] for body in recorder.bodies] == [ + ["a", "b"], + ["c"], + ] + + +def test_splits_batches_at_byte_limit_without_reordering(monkeypatch): + recorder = Recorder(_responder(ACCEPTED)) + queue: Queue = Queue() + scores = [_score(name, comment="x" * 100) for name in ("a", "b", "c")] + for score in scores: + queue.put(score) + item_size = len(json.dumps(scores[0]).encode()) + # Room for two items, their separator and the list brackets, not three. + monkeypatch.setattr( + score_consumer_module, "MAX_BATCH_SIZE_BYTES", 2 * item_size + 3 + ) + + _drain(_consumer(recorder, queue), queue) + + assert [[s["name"] for s in body] for body in recorder.bodies] == [ + ["a", "b"], + ["c"], + ] + assert all(len(r.content) <= 2 * item_size + 3 for r in recorder.requests) + + +def test_drops_score_larger_than_byte_limit(monkeypatch, caplog): + recorder = Recorder(_responder(ACCEPTED)) + queue: Queue = Queue() + queue.put(_score("huge", comment="x" * 500)) + queue.put(_score("small")) + monkeypatch.setattr(score_consumer_module, "MAX_BATCH_SIZE_BYTES", 200) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert [[s["name"] for s in body] for body in recorder.bodies] == [["small"]] + assert "huge" in caplog.text + + +def test_partial_success_is_logged_and_not_retried(caplog): + recorder = Recorder( + _responder( + ( + 207, + { + "accepted": 1, + "rejected": 1, + "errors": [{"message": "b: value must be numeric"}], + }, + ) + ) + ) + queue: Queue = Queue() + queue.put(_score("a")) + queue.put(_score("b", value="not-a-number")) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert len(recorder.requests) == 1 + assert "accepted 1 and rejected 1 of 2 scores" in caplog.text + assert "b: value must be numeric" in caplog.text + + +def test_single_score_response_is_accepted(caplog): + recorder = Recorder(_responder((200, {"id": "id-a"}))) + queue: Queue = Queue() + queue.put(_score("a")) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert len(recorder.requests) == 1 + assert caplog.records == [] + + +@pytest.mark.parametrize( + "failure", + [ + pytest.param((500, {"message": "boom"}), id="server-error"), + pytest.param((429, {"message": "slow down"}), id="rate-limited"), + pytest.param(httpx.ConnectError("connection refused"), id="connect-error"), + pytest.param(httpx.ReadTimeout("timed out"), id="timeout"), + ], +) +def test_retries_transient_failures(failure, caplog): + recorder = Recorder(_responder(failure, ACCEPTED)) + queue: Queue = Queue() + queue.put(_score("a")) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert len(recorder.requests) == 2 + assert recorder.bodies[0] == recorder.bodies[1] + assert caplog.records == [] + + +def test_gives_up_after_max_retries(caplog): + recorder = Recorder(_responder((503, {"message": "unavailable"}))) + queue: Queue = Queue() + queue.put(_score("a")) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue, max_retries=3), queue) + + assert len(recorder.requests) == 3 + assert queue.unfinished_tasks == 0 + assert "Service unavailable" in caplog.text + + +@pytest.mark.parametrize("status", [400, 401, 403, 404]) +def test_does_not_retry_client_errors(status, caplog): + recorder = Recorder(_responder((status, {"message": "nope"}))) + queue: Queue = Queue() + queue.put(_score("a")) + + with caplog.at_level(logging.ERROR, logger="langfuse"): + _drain(_consumer(recorder, queue), queue) + + assert len(recorder.requests) == 1 + assert queue.unfinished_tasks == 0 + assert caplog.records + + +def test_pause_sends_carried_over_score(monkeypatch): + first_request_started = threading.Event() + release_first_request = threading.Event() + accept = _responder(ACCEPTED) + + def respond(request: httpx.Request) -> httpx.Response: + if not first_request_started.is_set(): + first_request_started.set() + release_first_request.wait(timeout=5) + return accept(request) + + recorder = Recorder(respond) + queue: Queue = Queue() + scores = [_score(name) for name in ("a", "b")] + for score in scores: + queue.put(score) + monkeypatch.setattr( + score_consumer_module, + "MAX_BATCH_SIZE_BYTES", + len(json.dumps(scores[0]).encode()) + 2, + ) + consumer = _consumer(recorder, queue) + + consumer.start() + assert first_request_started.wait(timeout=5) + # "b" did not fit into the in-flight batch and waits as carry-over. + consumer.pause() + release_first_request.set() + consumer.join(timeout=5) + + assert not consumer.is_alive() + assert [[s["name"] for s in body] for body in recorder.bodies] == [["a"], ["b"]] + + +def _sampled_provider(rate: float) -> TracerProvider: + # An explicit provider, because the global one is set once per process. + return TracerProvider(sampler=TraceIdRatioBased(rate)) + + +@pytest.fixture +def client_factory(): + clients: List[Langfuse] = [] + + def create(recorder: Recorder, **kwargs: Any) -> Langfuse: + with LangfuseResourceManager._lock: + LangfuseResourceManager._instances.clear() + client = Langfuse( + public_key="pk-score-consumer", + secret_key="sk-score-consumer", + base_url="http://langfuse.test", + httpx_client=httpx.Client(transport=httpx.MockTransport(recorder)), + span_exporter=NoOpSpanExporter(), + **kwargs, + ) + clients.append(client) + return client + + yield create + + for client in clients: + client.shutdown() + with LangfuseResourceManager._lock: + LangfuseResourceManager._instances.clear() + + +def test_create_score_posts_to_scores_endpoint(client_factory): + recorder = Recorder(_responder(ACCEPTED)) + client = client_factory(recorder, environment="staging") + timestamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + client.create_score( + score_id="score-1", + name="accuracy", + value=0.9, + trace_id="a" * 32, + observation_id="b" * 16, + data_type="NUMERIC", + comment="fine", + config_id="config-1", + metadata="not a dict", + timestamp=timestamp, + ) + client.create_score(name="session-score", value="good", session_id="session-1") + client.flush() + + assert {r.url.path for r in recorder.requests} == {"/api/public/scores"} + sent = [score for body in recorder.bodies for score in body] + assert sent[0] == { + "id": "score-1", + "traceId": "a" * 32, + "observationId": "b" * 16, + "name": "accuracy", + "value": 0.9, + "dataType": "NUMERIC", + "comment": "fine", + "configId": "config-1", + "environment": "staging", + "metadata": "not a dict", + "timestamp": "2026-01-02T03:04:05Z", + } + assert sent[1]["sessionId"] == "session-1" + assert sent[1]["name"] == "session-score" + assert "traceId" not in sent[1] + assert sent[1]["timestamp"].endswith("Z") + + +@pytest.fixture +def non_utc_local_timezone(monkeypatch): + # Makes treating naive values as local time observable. + monkeypatch.setenv("TZ", "America/New_York") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + +def test_naive_score_timestamp_is_sent_as_utc(client_factory, non_utc_local_timezone): + recorder = Recorder(_responder(ACCEPTED)) + client = client_factory(recorder) + + client.create_score( + name="naive", + value=1.0, + session_id="session-1", + timestamp=datetime(2026, 1, 2, 3, 4, 5), + ) + client.flush() + + sent = [score for body in recorder.bodies for score in body] + assert sent[0]["timestamp"] == "2026-01-02T03:04:05Z" + + +def test_scores_follow_trace_sampling(client_factory): + recorder = Recorder(_responder(ACCEPTED)) + client = client_factory(recorder, tracer_provider=_sampled_provider(0.5)) + + # The sampler keeps a trace when its low 64 bits fall below rate * 2^64. + client.create_score(name="sampled", value=1.0, trace_id="0" * 32) + client.create_score(name="dropped", value=1.0, trace_id="f" * 32) + client.create_score(name="session", value=1.0, session_id="session-1") + client.create_score(name="dataset-run", value=1.0, dataset_run_id="run-1") + client.flush() + + sent = [score["name"] for body in recorder.bodies for score in body] + assert sent == ["sampled", "session", "dataset-run"] + + +def test_scores_for_sampled_out_traces_are_dropped(client_factory): + recorder = Recorder(_responder(ACCEPTED)) + client = client_factory(recorder, tracer_provider=_sampled_provider(0.0)) + + client.create_score(name="dropped", value=1.0, trace_id="0" * 32) + with client.start_as_current_observation(name="span") as span: + span.score(name="dropped-span", value=1.0) + span.score_trace(name="dropped-trace", value=1.0) + client.flush() + + assert recorder.requests == [] + + +def test_scores_are_kept_when_sampler_decision_depends_on_more_than_trace_id( + client_factory, +): + recorder = Recorder(_responder(ACCEPTED)) + provider = TracerProvider(sampler=ParentBased(ALWAYS_OFF)) + client = client_factory(recorder, tracer_provider=provider) + + client.create_score(name="kept", value=1.0, trace_id="0" * 32) + client.flush() + + sent = [score["name"] for body in recorder.bodies for score in body] + assert sent == ["kept"] From 43412fbce3ab5dca5d5708fc1f3d320580fd4b11 Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Tue, 13 Oct 2026 16:19:00 +0200 Subject: [PATCH 24/31] feat(api): update API spec from langfuse/langfuse 99da25d (#1990) Co-authored-by: langfuse-bot --- langfuse/api/__init__.py | 3 - langfuse/api/client.py | 19 -- langfuse/api/legacy/__init__.py | 40 ---- langfuse/api/legacy/client.py | 63 ------ langfuse/api/legacy/raw_client.py | 13 -- langfuse/api/legacy/score_v1/__init__.py | 4 - langfuse/api/legacy/score_v1/client.py | 123 ---------- langfuse/api/legacy/score_v1/raw_client.py | 214 ------------------ .../types/create_chat_prompt_request.py | 6 +- .../types/create_text_prompt_request.py | 6 +- langfuse/api/scores/client.py | 84 +++++++ langfuse/api/scores/raw_client.py | 189 ++++++++++++++++ .../api/scores/types/create_score_request.py | 2 +- 13 files changed, 284 insertions(+), 482 deletions(-) delete mode 100644 langfuse/api/legacy/__init__.py delete mode 100644 langfuse/api/legacy/client.py delete mode 100644 langfuse/api/legacy/raw_client.py delete mode 100644 langfuse/api/legacy/score_v1/__init__.py delete mode 100644 langfuse/api/legacy/score_v1/client.py delete mode 100644 langfuse/api/legacy/score_v1/raw_client.py diff --git a/langfuse/api/__init__.py b/langfuse/api/__init__.py index 55f330350..1b85b23c2 100644 --- a/langfuse/api/__init__.py +++ b/langfuse/api/__init__.py @@ -21,7 +21,6 @@ feedback, health, ingestion, - legacy, llm_connections, media, metrics, @@ -799,7 +798,6 @@ "feedback": ".feedback", "health": ".health", "ingestion": ".ingestion", - "legacy": ".legacy", "llm_connections": ".llm_connections", "media": ".media", "metrics": ".metrics", @@ -1215,7 +1213,6 @@ def __dir__(): "feedback", "health", "ingestion", - "legacy", "llm_connections", "media", "metrics", diff --git a/langfuse/api/client.py b/langfuse/api/client.py index 1d4de400d..4bbf80dbd 100644 --- a/langfuse/api/client.py +++ b/langfuse/api/client.py @@ -28,7 +28,6 @@ from .feedback.client import AsyncFeedbackClient, FeedbackClient from .health.client import AsyncHealthClient, HealthClient from .ingestion.client import AsyncIngestionClient, IngestionClient - from .legacy.client import AsyncLegacyClient, LegacyClient from .llm_connections.client import AsyncLlmConnectionsClient, LlmConnectionsClient from .media.client import AsyncMediaClient, MediaClient from .metrics.client import AsyncMetricsClient, MetricsClient @@ -138,7 +137,6 @@ def __init__( self._feedback: typing.Optional[FeedbackClient] = None self._health: typing.Optional[HealthClient] = None self._ingestion: typing.Optional[IngestionClient] = None - self._legacy: typing.Optional[LegacyClient] = None self._llm_connections: typing.Optional[LlmConnectionsClient] = None self._media: typing.Optional[MediaClient] = None self._metrics: typing.Optional[MetricsClient] = None @@ -252,14 +250,6 @@ def ingestion(self): self._ingestion = IngestionClient(client_wrapper=self._client_wrapper) return self._ingestion - @property - def legacy(self): - if self._legacy is None: - from .legacy.client import LegacyClient # noqa: E402 - - self._legacy = LegacyClient(client_wrapper=self._client_wrapper) - return self._legacy - @property def llm_connections(self): if self._llm_connections is None: @@ -490,7 +480,6 @@ def __init__( self._feedback: typing.Optional[AsyncFeedbackClient] = None self._health: typing.Optional[AsyncHealthClient] = None self._ingestion: typing.Optional[AsyncIngestionClient] = None - self._legacy: typing.Optional[AsyncLegacyClient] = None self._llm_connections: typing.Optional[AsyncLlmConnectionsClient] = None self._media: typing.Optional[AsyncMediaClient] = None self._metrics: typing.Optional[AsyncMetricsClient] = None @@ -610,14 +599,6 @@ def ingestion(self): self._ingestion = AsyncIngestionClient(client_wrapper=self._client_wrapper) return self._ingestion - @property - def legacy(self): - if self._legacy is None: - from .legacy.client import AsyncLegacyClient # noqa: E402 - - self._legacy = AsyncLegacyClient(client_wrapper=self._client_wrapper) - return self._legacy - @property def llm_connections(self): if self._llm_connections is None: diff --git a/langfuse/api/legacy/__init__.py b/langfuse/api/legacy/__init__.py deleted file mode 100644 index 68d217597..000000000 --- a/langfuse/api/legacy/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from . import score_v1 -_dynamic_imports: typing.Dict[str, str] = {"score_v1": ".score_v1"} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = ["score_v1"] diff --git a/langfuse/api/legacy/client.py b/langfuse/api/legacy/client.py deleted file mode 100644 index 8e5dd2899..000000000 --- a/langfuse/api/legacy/client.py +++ /dev/null @@ -1,63 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -from __future__ import annotations - -import typing - -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from .raw_client import AsyncRawLegacyClient, RawLegacyClient - -if typing.TYPE_CHECKING: - from .score_v1.client import AsyncScoreV1Client, ScoreV1Client - - -class LegacyClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawLegacyClient(client_wrapper=client_wrapper) - self._client_wrapper = client_wrapper - self._score_v1: typing.Optional[ScoreV1Client] = None - - @property - def with_raw_response(self) -> RawLegacyClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawLegacyClient - """ - return self._raw_client - - @property - def score_v1(self): - if self._score_v1 is None: - from .score_v1.client import ScoreV1Client # noqa: E402 - - self._score_v1 = ScoreV1Client(client_wrapper=self._client_wrapper) - return self._score_v1 - - -class AsyncLegacyClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawLegacyClient(client_wrapper=client_wrapper) - self._client_wrapper = client_wrapper - self._score_v1: typing.Optional[AsyncScoreV1Client] = None - - @property - def with_raw_response(self) -> AsyncRawLegacyClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawLegacyClient - """ - return self._raw_client - - @property - def score_v1(self): - if self._score_v1 is None: - from .score_v1.client import AsyncScoreV1Client # noqa: E402 - - self._score_v1 = AsyncScoreV1Client(client_wrapper=self._client_wrapper) - return self._score_v1 diff --git a/langfuse/api/legacy/raw_client.py b/langfuse/api/legacy/raw_client.py deleted file mode 100644 index 0672ed01d..000000000 --- a/langfuse/api/legacy/raw_client.py +++ /dev/null @@ -1,13 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper - - -class RawLegacyClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - -class AsyncRawLegacyClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper diff --git a/langfuse/api/legacy/score_v1/__init__.py b/langfuse/api/legacy/score_v1/__init__.py deleted file mode 100644 index 5cde0202d..000000000 --- a/langfuse/api/legacy/score_v1/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - diff --git a/langfuse/api/legacy/score_v1/client.py b/langfuse/api/legacy/score_v1/client.py deleted file mode 100644 index 57608d03c..000000000 --- a/langfuse/api/legacy/score_v1/client.py +++ /dev/null @@ -1,123 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.request_options import RequestOptions -from .raw_client import AsyncRawScoreV1Client, RawScoreV1Client - - -class ScoreV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawScoreV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawScoreV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawScoreV1Client - """ - return self._raw_client - - def delete( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> None: - """ - Delete a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - None - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.legacy.score_v1.delete( - score_id="scoreId", - ) - """ - _response = self._raw_client.delete(score_id, request_options=request_options) - return _response.data - - -class AsyncScoreV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawScoreV1Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawScoreV1Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawScoreV1Client - """ - return self._raw_client - - async def delete( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> None: - """ - Delete a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - None - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.legacy.score_v1.delete( - score_id="scoreId", - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.delete( - score_id, request_options=request_options - ) - return _response.data diff --git a/langfuse/api/legacy/score_v1/raw_client.py b/langfuse/api/legacy/score_v1/raw_client.py deleted file mode 100644 index 6abeb5245..000000000 --- a/langfuse/api/legacy/score_v1/raw_client.py +++ /dev/null @@ -1,214 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing -from json.decoder import JSONDecodeError - -from ...commons.errors.access_denied_error import AccessDeniedError -from ...commons.errors.error import Error -from ...commons.errors.method_not_allowed_error import MethodNotAllowedError -from ...commons.errors.not_found_error import NotFoundError -from ...commons.errors.unauthorized_error import UnauthorizedError -from ...core.api_error import ApiError -from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.http_response import AsyncHttpResponse, HttpResponse -from ...core.jsonable_encoder import jsonable_encoder -from ...core.pydantic_utilities import parse_obj_as -from ...core.request_options import RequestOptions - - -class RawScoreV1Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - def delete( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> HttpResponse[None]: - """ - Delete a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[None] - """ - _response = self._client_wrapper.httpx_client.request( - f"api/public/scores/{jsonable_encoder(score_id)}", - method="DELETE", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - return HttpResponse(response=_response, data=None) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawScoreV1Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - async def delete( - self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None - ) -> AsyncHttpResponse[None]: - """ - Delete a score (supports both trace and session scores) - - Parameters - ---------- - score_id : str - The unique langfuse identifier of a score - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[None] - """ - _response = await self._client_wrapper.httpx_client.request( - f"api/public/scores/{jsonable_encoder(score_id)}", - method="DELETE", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - return AsyncHttpResponse(response=_response, data=None) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/prompts/types/create_chat_prompt_request.py b/langfuse/api/prompts/types/create_chat_prompt_request.py index 0fe1de0c1..36712f52e 100644 --- a/langfuse/api/prompts/types/create_chat_prompt_request.py +++ b/langfuse/api/prompts/types/create_chat_prompt_request.py @@ -11,7 +11,11 @@ class CreateChatPromptRequest(UniversalBaseModel): - name: str + name: str = pydantic.Field() + """ + The full prompt name, including any folder path, with at most 255 characters. + """ + prompt: typing.List[ChatMessageWithPlaceholders] config: typing.Optional[typing.Any] = None type: CreateChatPromptType diff --git a/langfuse/api/prompts/types/create_text_prompt_request.py b/langfuse/api/prompts/types/create_text_prompt_request.py index be87a7dde..8728da486 100644 --- a/langfuse/api/prompts/types/create_text_prompt_request.py +++ b/langfuse/api/prompts/types/create_text_prompt_request.py @@ -10,7 +10,11 @@ class CreateTextPromptRequest(UniversalBaseModel): - name: str + name: str = pydantic.Field() + """ + The full prompt name, including any folder path, with at most 255 characters. + """ + prompt: str config: typing.Optional[typing.Any] = None type: typing.Optional[CreateTextPromptType] = None diff --git a/langfuse/api/scores/client.py b/langfuse/api/scores/client.py index 6698c6ccf..3690d8b57 100644 --- a/langfuse/api/scores/client.py +++ b/langfuse/api/scores/client.py @@ -72,6 +72,43 @@ def create( ) return _response.data + def delete( + self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Delete a score (supports both trace and session scores) + + Parameters + ---------- + score_id : str + The unique langfuse identifier of a score + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.scores.delete( + score_id="scoreId", + ) + """ + _response = self._raw_client.delete(score_id, request_options=request_options) + return _response.data + class AsyncScoresClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -140,3 +177,50 @@ async def main() -> None: request=request, request_options=request_options ) return _response.data + + async def delete( + self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Delete a score (supports both trace and session scores) + + Parameters + ---------- + score_id : str + The unique langfuse identifier of a score + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.scores.delete( + score_id="scoreId", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete( + score_id, request_options=request_options + ) + return _response.data diff --git a/langfuse/api/scores/raw_client.py b/langfuse/api/scores/raw_client.py index 09a77b575..f9f75efc9 100644 --- a/langfuse/api/scores/raw_client.py +++ b/langfuse/api/scores/raw_client.py @@ -11,6 +11,7 @@ from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.jsonable_encoder import jsonable_encoder from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions from ..core.serialization import convert_and_respect_annotation_metadata @@ -132,6 +133,100 @@ def create( body=_response_json, ) + def delete( + self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: + """ + Delete a score (supports both trace and session scores) + + Parameters + ---------- + score_id : str + The unique langfuse identifier of a score + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + f"api/public/scores/{jsonable_encoder(score_id)}", + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + class AsyncRawScoresClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -243,3 +338,97 @@ async def create( headers=dict(_response.headers), body=_response_json, ) + + async def delete( + self, score_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Delete a score (supports both trace and session scores) + + Parameters + ---------- + score_id : str + The unique langfuse identifier of a score + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/public/scores/{jsonable_encoder(score_id)}", + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) diff --git a/langfuse/api/scores/types/create_score_request.py b/langfuse/api/scores/types/create_score_request.py index cb572ccb6..a9453c452 100644 --- a/langfuse/api/scores/types/create_score_request.py +++ b/langfuse/api/scores/types/create_score_request.py @@ -79,7 +79,7 @@ class CreateScoreRequest(UniversalBaseModel): timestamp: typing.Optional[dt.datetime] = pydantic.Field(default=None) """ - The time of the score. Defaults to the time the request is received. + The timestamp of the score, as an ISO 8601 datetime with a timezone offset. Defaults to the time the request is received. To update an existing score, send it again with the same id and timestamp. """ model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( From abbc6cb24d2af237ffec029fa44b017d295af4ff Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:35:11 +0400 Subject: [PATCH 25/31] chore: merge main into prepare-v5-release (#1992) * docs(api): document metadata is set / is not set filter operators (#1946) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17357 and #18404. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * fix(api): type Dataset JSON-schema response fields as objects (#1949) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17892 and the datasets.yml/opentelemetry.yml part of #18406. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * refactor(api): share cursor pagination meta via CursorMetaResponse (#1948) Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17888. Split out of the bundled API-spec bot PR #1943. Co-authored-by: Claude Opus 5.5 (1M context) * docs(api): document fromStartTime as required on experiments endpoints (#1947) * docs(api): document fromStartTime as required on experiments endpoints Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17603. Split out of the bundled API-spec bot PR #1943. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(api): use language-neutral wording for experiments time window Regenerated from langfuse/langfuse branch docs/experiments-from-start-time-neutral-wording. The fromStartTime docstrings named `toStartTime`, but the Python parameter is `to_start_time`; they now say "an upper bound" instead. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix(api): type read-side metadata as optional objects (#1950) * fix(api): type read-side metadata as optional objects Regenerated langfuse/api from the upstream Fern definition: baseline langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#18122 and the commons.yml part of #18406. Split out of the bundled API-spec bot PR #1943. Co-Authored-By: Claude Opus 5.5 (1M context) * test(api): pin read-side metadata parsing on generated models Cover omitted, null, empty and nested-JSON metadata plus rejection of non-object metadata for Trace, Observation, ObservationV2, BaseScore, BaseScoreV1 and every Score / ScoreV1 / GetScoresResponseData variant. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Cursor Agent From 7f7b714c950d29ca9c98c5404a322ecb9b4c40ce Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:26:48 +0400 Subject: [PATCH 26/31] fix: address v5 release review findings (#1993) * fix: budget propagated trace metadata against the span attribute limit Co-authored-by: hassieb.pakzad * fix: gate experiment run scores on the item span's sampling decision Co-authored-by: hassieb.pakzad * fix(batch-evaluation): skip boundary duplicates on resume and fetch updatedAt for dedup Co-authored-by: hassieb.pakzad * style: format span metadata key helper Co-authored-by: hassieb.pakzad * fix: count experiment items as sampled only after experiment attributes are set Co-authored-by: hassieb.pakzad * fix(batch-evaluation): request updatedAt when no field groups are given Co-authored-by: hassieb.pakzad --------- Co-authored-by: Cursor Agent --- langfuse/_client/client.py | 43 ++++-------- langfuse/_client/span.py | 26 ++++---- langfuse/batch_evaluation.py | 26 +++++++- tests/unit/test_batch_evaluation.py | 36 +++++++++- tests/unit/test_experiment.py | 74 +++++++++++++++++++++ tests/unit/test_metadata_attribute_limit.py | 42 ++++++++++-- 6 files changed, 197 insertions(+), 50 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index e7be8214b..e4261bc30 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -4,6 +4,7 @@ """ import asyncio +import json import logging import os import re @@ -19,6 +20,7 @@ List, Literal, Optional, + Set, Tuple, Type, Union, @@ -110,6 +112,7 @@ Prompt_Chat, Prompt_Text, ) +from langfuse.api.core.datetime_utils import serialize_datetime from langfuse.batch_evaluation import ( BatchEvaluationResult, BatchEvaluationResumeToken, @@ -2673,6 +2676,7 @@ async def _run_experiment_async( # Set up concurrency control semaphore = asyncio.Semaphore(max_concurrency) + sampled_trace_ids: Set[str] = set() # Process all items async def process_item(item: ExperimentItem) -> ExperimentItemResult: @@ -2688,6 +2692,7 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: description, metadata, experiment_item_version, + sampled_trace_ids, ) # Run all items concurrently @@ -2721,14 +2726,7 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: # Without an exported item span the experiment does not exist on the server, # so a run score would be orphaned. - stored_run_evaluations = ( - run_evaluations - if any( - result.trace_id is not None and self._is_trace_sampled(result.trace_id) - for result in valid_results - ) - else [] - ) + stored_run_evaluations = run_evaluations if sampled_trace_ids else [] if run_evaluations and not stored_run_evaluations: langfuse_logger.debug( "Skipping run-level scores for experiment %s: all items were sampled out.", @@ -2782,8 +2780,6 @@ def _create_experiment_id( ) return self._create_observation_id() - import json - # Must match the Langfuse server's stable experiment id derivation byte for # byte (JS JSON.stringify of the same array, SHA-256, first 16 hex chars). payload = json.dumps( @@ -2794,27 +2790,8 @@ def _create_experiment_id( return sha256(payload.encode("utf-8")).hexdigest()[:16] - def _is_trace_sampled(self, trace_id: str) -> bool: - from opentelemetry.sdk.trace.sampling import Decision - - tracer_provider = self._resources.tracer_provider if self._resources else None - sampler = getattr(tracer_provider, "sampler", None) - if sampler is None: - return True - - try: - decision = sampler.should_sample( - parent_context=None, trace_id=int(trace_id, 16), name="experiment" - ).decision - except Exception: - return True - - return bool(decision == Decision.RECORD_AND_SAMPLE) - @staticmethod def _format_experiment_item_version(dataset_version: datetime) -> str: - from langfuse.api.core.datetime_utils import serialize_datetime - return serialize_datetime(dataset_version) async def _process_experiment_item( @@ -2829,6 +2806,7 @@ async def _process_experiment_item( experiment_description: Optional[str], experiment_metadata: Optional[Dict[str, Any]] = None, experiment_item_version: Optional[str] = None, + sampled_trace_ids: Optional[Set[str]] = None, ) -> ExperimentItemResult: with self.start_as_current_observation(name="experiment-item-run") as span: try: @@ -2937,6 +2915,13 @@ async def _process_experiment_item( otel_context_api.get_current() ), ) + # Only a sampled span carrying the experiment attributes + # creates the experiment on the server. + if ( + sampled_trace_ids is not None + and span._otel_span.get_span_context().trace_flags.sampled + ): + sampled_trace_ids.add(span.trace_id) # Write the observation metadata after the experiment # attributes, so the span attribute limit trims the # metadata instead of leaving no room for the output. diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 54ce58308..ffdcd8c55 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -64,9 +64,17 @@ _OBSERVATION_CLASS_MAP: Dict[str, Type["LangfuseObservationWrapper"]] = {} -def _is_observation_metadata_key(key: str) -> bool: - prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA - return key == prefix or key.startswith(prefix + ".") +_METADATA_KEY_PREFIXES: Tuple[str, ...] = ( + LangfuseOtelSpanAttributes.OBSERVATION_METADATA, + LangfuseOtelSpanAttributes.TRACE_METADATA, +) + + +def _is_metadata_key(key: str) -> bool: + return any( + key == prefix or key.startswith(prefix + ".") + for prefix in _METADATA_KEY_PREFIXES + ) # Attributes the SDK may write in later updates of an observation. Metadata @@ -151,9 +159,7 @@ def _drop_attributes_over_span_limit( if len(existing) + reserved_count + len(new_keys) <= max_attributes: return attributes - new_metadata_keys = [ - key for key in new_keys if _is_observation_metadata_key(key) - ] + new_metadata_keys = [key for key in new_keys if _is_metadata_key(key)] free_metadata_slots = max( 0, max_attributes @@ -171,12 +177,8 @@ def _drop_attributes_over_span_limit( dropped_other = remaining_new_keys[capacity:] # The metadata budget is never looser than the hard guard, so only # non-metadata keys can be dropped here. Count any metadata defensively. - dropped_metadata += [ - key for key in dropped_other if _is_observation_metadata_key(key) - ] - dropped_other = [ - key for key in dropped_other if not _is_observation_metadata_key(key) - ] + dropped_metadata += [key for key in dropped_other if _is_metadata_key(key)] + dropped_other = [key for key in dropped_other if not _is_metadata_key(key)] if not dropped_metadata and not dropped_other: return attributes diff --git a/langfuse/batch_evaluation.py b/langfuse/batch_evaluation.py index a761f67ce..4d7bf3371 100644 --- a/langfuse/batch_evaluation.py +++ b/langfuse/batch_evaluation.py @@ -39,6 +39,17 @@ """ +def _with_time_fields(fields: Optional[str]) -> str: + """Add the ``time`` group, whose ``updatedAt`` picks the newest duplicate row.""" + groups = [group.strip() for group in (fields or "").split(",") if group.strip()] + if not groups: + # The API's default when no groups are requested. + groups = ["core", "basic"] + if "time" not in groups: + groups.append("time") + return ",".join(groups) + + class EvaluatorInputs: """Input data structure for evaluators, returned by mapper functions. @@ -469,6 +480,9 @@ class BatchEvaluationResumeToken: observation. Only used to resume when `cursor` is None, by fetching observations that started strictly before this timestamp. last_processed_id: The ID of the last processed observation, for reference. + last_page_ids: IDs of the observations on the last processed page. A + resumed run skips these, because unmerged duplicate rows of one + observation can span a page boundary. items_processed: Number of items successfully processed so far, including items processed by the runs this token was resumed from. @@ -515,6 +529,7 @@ def __init__( last_processed_id: str, items_processed: int, cursor: Optional[str] = None, + last_page_ids: Optional[List[str]] = None, ): """Initialize BatchEvaluationResumeToken with the provided state. @@ -524,6 +539,7 @@ def __init__( last_processed_id: ID of last processed item. items_processed: Count of items processed before interruption. cursor: Cursor of the next page to fetch. + last_page_ids: IDs of the observations on the last processed page. Note: All arguments must be provided as keywords. @@ -533,6 +549,7 @@ def __init__( self.last_processed_timestamp = last_processed_timestamp self.last_processed_id = last_processed_id self.items_processed = items_processed + self.last_page_ids = last_page_ids class BatchEvaluationResult: @@ -896,7 +913,9 @@ async def run_async( and resume_from.last_processed_timestamp else None ) - previous_page_ids: Set[Optional[str]] = set() + previous_page_ids: Set[Optional[str]] = set( + (resume_from.last_page_ids or []) if resume_from else [] + ) batch_number = 0 if verbose: @@ -917,6 +936,9 @@ def build_resume_token() -> BatchEvaluationResumeToken: last_processed_timestamp=last_item_timestamp, last_processed_id=last_item_id, items_processed=previously_processed + total_items_processed, + last_page_ids=sorted( + item_id for item_id in previous_page_ids if item_id is not None + ), ) while True: @@ -1091,7 +1113,7 @@ async def _fetch_batch_with_retry( """ response = await asyncio.to_thread( self.client.api.observations.get_many, - fields=fields, + fields=_with_time_fields(fields), limit=limit, cursor=cursor, filter=filter, diff --git a/tests/unit/test_batch_evaluation.py b/tests/unit/test_batch_evaluation.py index 1ac1751fa..f6c29f4f6 100644 --- a/tests/unit/test_batch_evaluation.py +++ b/tests/unit/test_batch_evaluation.py @@ -117,7 +117,8 @@ async def test_paginates_with_cursor_and_scores_observations(): assert [call["cursor"] for call in api.calls] == [None, "2", "4"] assert all("page" not in call for call in api.calls) assert all( - call["fields"] == DEFAULT_BATCH_EVALUATION_FIELDS and call["filter"] is None + call["fields"] == f"{DEFAULT_BATCH_EVALUATION_FIELDS},time" + and call["filter"] is None for call in api.calls ) assert result.completed is True @@ -151,11 +152,21 @@ def recording_mapper(*, item): await run(runner, mapper=recording_mapper, fields="core,io") - assert api.calls[0]["fields"] == "core,io" + assert api.calls[0]["fields"] == "core,io,time" assert isinstance(seen[0], ObservationV2) assert seen[0].input == '{"question": 1}' +@pytest.mark.asyncio +@pytest.mark.parametrize("fields", [None, ""]) +async def test_unset_fields_request_the_api_defaults_plus_time(fields): + runner, api, _ = make_runner([make_observation(1)]) + + await run(runner, fields=fields) + + assert api.calls[0]["fields"] == "core,basic,time" + + def test_client_and_runner_share_default_fields(): client_default = ( inspect.signature(Langfuse.run_batched_evaluation).parameters["fields"].default @@ -378,3 +389,24 @@ def recording_mapper(*, item): assert [item.output for item in seen if item.id == "obs-1"] == ["fresh answer"] assert result.total_items_processed == 2 assert client.create_score.call_count == 2 + + +@pytest.mark.asyncio +async def test_resume_skips_duplicate_rows_split_across_the_page_boundary(): + duplicate = make_observation(2).model_copy( + update={"updated_at": BASE_TIME + timedelta(hours=1)} + ) + runner, _, client = make_runner( + [make_observation(i) for i in range(5)] + [duplicate] + ) + + first = await run(runner, max_items=3, fetch_batch_size=3) + assert first.resume_token is not None + assert first.resume_token.last_page_ids == ["obs-2", "obs-3", "obs-4"] + + await run(runner, resume_from=first.resume_token, fetch_batch_size=3) + + scored_ids = [ + call.kwargs["observation_id"] for call in client.create_score.call_args_list + ] + assert sorted(scored_ids) == [f"obs-{i}" for i in range(5)] diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index 192577b6b..e8e7037d9 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -768,6 +768,80 @@ def test_run_scores_skipped_when_all_items_are_sampled_out( assert [e.name for e in result.run_evaluations] == ["run"] assert _run_scores(create_score) == [] + def test_run_scores_follow_the_item_span_sampling_decision( + self, langfuse_memory_client, monkeypatch + ): + from opentelemetry.sdk.trace.sampling import ( + Decision, + Sampler, + SamplingResult, + ) + + class ItemSpanSampler(Sampler): + def should_sample(self, parent_context, trace_id, name, *args, **kwargs): + decision = ( + Decision.RECORD_AND_SAMPLE + if name.startswith("experiment-item") + else Decision.DROP + ) + return SamplingResult(decision) + + def get_description(self): + return "ItemSpanSampler" + + sampler = ItemSpanSampler() + create_score = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + monkeypatch.setattr( + langfuse_memory_client._resources.tracer_provider, "sampler", sampler + ) + monkeypatch.setattr(langfuse_memory_client._otel_tracer, "sampler", sampler) + + langfuse_memory_client.run_experiment( + name="exp", + data=[{"input": "a"}], + task=lambda *, item, **kwargs: item["input"], + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + ) + + assert len(_run_scores(create_score)) == 1 + + def test_run_scores_skipped_when_all_items_fail_before_experiment_attributes( + self, langfuse_memory_client, monkeypatch + ): + create_score = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + + langfuse_memory_client.run_experiment( + name="exp", + data=[{"input": None}], + task=lambda *, item, **kwargs: item["input"], + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + ) + + assert _run_scores(create_score) == [] + + def test_run_scores_kept_when_the_task_fails_after_experiment_attributes( + self, langfuse_memory_client, monkeypatch + ): + def failing_task(*, item, **kwargs): + raise RuntimeError("boom") + + create_score = MagicMock() + monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) + monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") + + langfuse_memory_client.run_experiment( + name="exp", + data=[{"input": "a"}], + task=failing_task, + run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], + ) + + assert len(_run_scores(create_score)) == 1 + def test_tracing_disabled_skips_project_id_lookup(self, monkeypatch): client = Langfuse( public_key="pk", secret_key="sk", base_url="http://x", tracing_enabled=False diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index edf517d1f..54bb919a4 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -229,6 +229,35 @@ def test_propagated_trace_attributes_count_toward_limit( assert len(_limit_warnings(caplog)) == 1 +def test_propagated_metadata_over_limit_keeps_core_attributes( + small_limit_client, get_span +): + with propagate_attributes( + metadata={f"trace_{i}": str(i) for i in range(60)}, + ): + generation = small_limit_client.start_observation( + name="propagated-generation", + as_type="generation", + input="the input", + model="gpt-4o", + ) + generation.update(output="the output") + generation.end() + small_limit_client.flush() + + span = get_span("propagated-generation") + attributes = span.attributes + assert span.dropped_attributes == 0 + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o" + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" + assert any( + key.startswith(LangfuseOtelSpanAttributes.TRACE_METADATA + ".") + for key in attributes + ) + + def test_metadata_under_limit_is_unchanged(langfuse_memory_client, get_span, caplog): metadata = {f"key_{i}": i for i in range(50)} span_wrapper = langfuse_memory_client.start_observation( @@ -406,7 +435,7 @@ def test_warning_message_format(small_limit_client, caplog): assert all(key.startswith("key_") for key in dropped_keys) -def test_new_attributes_beyond_capacity_drop_newest_instead_of_evicting( +def test_propagated_metadata_on_full_span_drops_newest_instead_of_evicting( small_limit_client, get_span, caplog ): with small_limit_client.start_as_current_observation( @@ -420,6 +449,9 @@ def test_new_attributes_beyond_capacity_drop_newest_instead_of_evicting( otel_span = generation._otel_span free_slots = 40 - len(otel_span.attributes) assert 0 < free_slots < 20 + # The free slots are the ones reserved for later updates, so propagated + # metadata gets none of them. + metadata_slots = max(0, free_slots - len(_missing_reserved_keys(otel_span))) attributes_before = dict(otel_span.attributes) caplog.clear() @@ -433,7 +465,7 @@ def test_new_attributes_beyond_capacity_drop_newest_instead_of_evicting( attributes = span.attributes assert span.dropped_attributes == 0 - assert len(attributes) == 40 + assert len(attributes) == 40 - free_slots + metadata_slots for key, value in attributes_before.items(): assert attributes[key] == value assert attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT] is True @@ -444,14 +476,14 @@ def test_new_attributes_beyond_capacity_drop_newest_instead_of_evicting( trace_prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + "." kept_trace_keys = [key for key in attributes if key.startswith(trace_prefix)] assert kept_trace_keys == [ - f"{trace_prefix}trace_{i:02d}" for i in range(free_slots) + f"{trace_prefix}trace_{i:02d}" for i in range(metadata_slots) ] warnings = _limit_warnings(caplog) assert len(warnings) == 1 message = warnings[0].getMessage() - assert f"0 metadata key(s) and {20 - free_slots} other attribute(s)" in message - assert f"{trace_prefix}trace_{free_slots:02d}" in message + assert f"Dropped {20 - metadata_slots} metadata key(s) from" in message + assert f"{trace_prefix}trace_{metadata_slots:02d}" in message def _fake_span(limit, existing): From ae1e56467d97846e2c585af3e7e7a070f244222e Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Tue, 13 Oct 2026 17:32:34 +0200 Subject: [PATCH 27/31] feat(api): update API spec from langfuse/langfuse 738b05d (#1995) Co-authored-by: langfuse-bot --- langfuse/api/__init__.py | 59 +-- langfuse/api/client.py | 19 - langfuse/api/experiments/types/experiment.py | 2 +- .../api/experiments/types/experiment_item.py | 2 +- langfuse/api/scores/__init__.py | 69 +++ langfuse/api/scores/client.py | 316 ++++++++++++ langfuse/api/scores/raw_client.py | 449 +++++++++++++++++ langfuse/api/scores/types/__init__.py | 73 +++ .../types/base_score_v3.py | 0 .../types/boolean_score_v3.py | 0 .../types/categorical_score_v3.py | 0 .../types/correction_score_v3.py | 0 .../types/get_scores_v3meta.py | 0 .../types/get_scores_v3response.py | 0 .../types/numeric_score_v3.py | 0 .../types/score_subject_experiment_v3.py | 0 .../types/score_subject_observation_v3.py | 0 .../types/score_subject_session_v3.py | 0 .../types/score_subject_trace_v3.py | 0 .../types/score_subject_v3.py | 0 .../{scores_v3 => scores}/types/score_v3.py | 0 .../types/text_score_v3.py | 0 langfuse/api/scores_v3/__init__.py | 112 ----- langfuse/api/scores_v3/client.py | 355 ------------- langfuse/api/scores_v3/raw_client.py | 474 ------------------ langfuse/api/scores_v3/types/__init__.py | 114 ----- 26 files changed, 936 insertions(+), 1108 deletions(-) rename langfuse/api/{scores_v3 => scores}/types/base_score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/boolean_score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/categorical_score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/correction_score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/get_scores_v3meta.py (100%) rename langfuse/api/{scores_v3 => scores}/types/get_scores_v3response.py (100%) rename langfuse/api/{scores_v3 => scores}/types/numeric_score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_subject_experiment_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_subject_observation_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_subject_session_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_subject_trace_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_subject_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/score_v3.py (100%) rename langfuse/api/{scores_v3 => scores}/types/text_score_v3.py (100%) delete mode 100644 langfuse/api/scores_v3/__init__.py delete mode 100644 langfuse/api/scores_v3/client.py delete mode 100644 langfuse/api/scores_v3/raw_client.py delete mode 100644 langfuse/api/scores_v3/types/__init__.py diff --git a/langfuse/api/__init__.py b/langfuse/api/__init__.py index 1b85b23c2..b058ced5a 100644 --- a/langfuse/api/__init__.py +++ b/langfuse/api/__init__.py @@ -34,7 +34,6 @@ scim, score_configs, scores, - scores_v3, trace, unstable, utils, @@ -394,6 +393,10 @@ UpdateScoreConfigRequest, ) from .scores import ( + BaseScoreV3, + BooleanScoreV3, + CategoricalScoreV3, + CorrectionScoreV3, CreateScoreBatchError, CreateScoreBatchRequest, CreateScoreBatchResponse, @@ -403,12 +406,6 @@ CreateScoreSource, CreateScoresRequest, CreateScoresResponse, - ) - from .scores_v3 import ( - BaseScoreV3, - BooleanScoreV3, - CategoricalScoreV3, - CorrectionScoreV3, GetScoresV3Meta, GetScoresV3Response, NumericScoreV3, @@ -448,7 +445,7 @@ "BaseEvent": ".ingestion", "BasePrompt": ".prompts", "BaseScore": ".commons", - "BaseScoreV3": ".scores_v3", + "BaseScoreV3": ".scores", "BlobStorageExportFieldGroup": ".blob_storage_integrations", "BlobStorageExportFrequency": ".blob_storage_integrations", "BlobStorageExportMode": ".blob_storage_integrations", @@ -463,10 +460,10 @@ "BlobStorageSyncStatus": ".blob_storage_integrations", "BooleanEvaluationRuleFilter": ".evaluation_commons", "BooleanScore": ".commons", - "BooleanScoreV3": ".scores_v3", + "BooleanScoreV3": ".scores", "BulkConfig": ".scim", "CategoricalScore": ".commons", - "CategoricalScoreV3": ".scores_v3", + "CategoricalScoreV3": ".scores", "CategoryOptionsEvaluationRuleFilter": ".evaluation_commons", "ChatMessage": ".prompts", "ChatMessageType": ".prompts", @@ -480,7 +477,7 @@ "ConfigCategory": ".commons", "ConflictError": ".evaluation_errors", "CorrectionScore": ".commons", - "CorrectionScoreV3": ".scores_v3", + "CorrectionScoreV3": ".scores", "CreateAnnotationQueueAssignmentResponse": ".annotation_queues", "CreateAnnotationQueueItemRequest": ".annotation_queues", "CreateAnnotationQueueRequest": ".annotation_queues", @@ -604,8 +601,8 @@ "GetMediaResponse": ".media", "GetMediaUploadUrlRequest": ".media", "GetMediaUploadUrlResponse": ".media", - "GetScoresV3Meta": ".scores_v3", - "GetScoresV3Response": ".scores_v3", + "GetScoresV3Meta": ".scores", + "GetScoresV3Response": ".scores", "HealthResponse": ".health", "IngestionError": ".ingestion", "IngestionEvent": ".ingestion", @@ -647,7 +644,7 @@ "NumberEvaluationRuleFilter": ".evaluation_commons", "NumberObjectEvaluationRuleFilter": ".evaluation_commons", "NumericScore": ".commons", - "NumericScoreV3": ".scores_v3", + "NumericScoreV3": ".scores", "ObservationBody": ".ingestion", "ObservationLevel": ".commons", "ObservationType": ".ingestion", @@ -733,21 +730,21 @@ "ScoreDataType": ".commons", "ScoreEvent": ".ingestion", "ScoreSource": ".commons", - "ScoreSubjectExperimentV3": ".scores_v3", - "ScoreSubjectObservationV3": ".scores_v3", - "ScoreSubjectSessionV3": ".scores_v3", - "ScoreSubjectTraceV3": ".scores_v3", - "ScoreSubjectV3": ".scores_v3", - "ScoreSubjectV3_Experiment": ".scores_v3", - "ScoreSubjectV3_Observation": ".scores_v3", - "ScoreSubjectV3_Session": ".scores_v3", - "ScoreSubjectV3_Trace": ".scores_v3", - "ScoreV3": ".scores_v3", - "ScoreV3_Boolean": ".scores_v3", - "ScoreV3_Categorical": ".scores_v3", - "ScoreV3_Correction": ".scores_v3", - "ScoreV3_Numeric": ".scores_v3", - "ScoreV3_Text": ".scores_v3", + "ScoreSubjectExperimentV3": ".scores", + "ScoreSubjectObservationV3": ".scores", + "ScoreSubjectSessionV3": ".scores", + "ScoreSubjectTraceV3": ".scores", + "ScoreSubjectV3": ".scores", + "ScoreSubjectV3_Experiment": ".scores", + "ScoreSubjectV3_Observation": ".scores", + "ScoreSubjectV3_Session": ".scores", + "ScoreSubjectV3_Trace": ".scores", + "ScoreV3": ".scores", + "ScoreV3_Boolean": ".scores", + "ScoreV3_Categorical": ".scores", + "ScoreV3_Correction": ".scores", + "ScoreV3_Numeric": ".scores", + "ScoreV3_Text": ".scores", "Score_Boolean": ".commons", "Score_Categorical": ".commons", "Score_Correction": ".commons", @@ -762,7 +759,7 @@ "SubmitFeedbackResponse": ".feedback", "TextPrompt": ".prompts", "TextScore": ".commons", - "TextScoreV3": ".scores_v3", + "TextScoreV3": ".scores", "TooManyRequestsError": ".evaluation_errors", "TraceBody": ".ingestion", "TraceEvent": ".ingestion", @@ -811,7 +808,6 @@ "scim": ".scim", "score_configs": ".score_configs", "scores": ".scores", - "scores_v3": ".scores_v3", "trace": ".trace", "unstable": ".unstable", "utils": ".utils", @@ -1226,7 +1222,6 @@ def __dir__(): "scim", "score_configs", "scores", - "scores_v3", "trace", "unstable", "utils", diff --git a/langfuse/api/client.py b/langfuse/api/client.py index 4bbf80dbd..30708f728 100644 --- a/langfuse/api/client.py +++ b/langfuse/api/client.py @@ -41,7 +41,6 @@ from .scim.client import AsyncScimClient, ScimClient from .score_configs.client import AsyncScoreConfigsClient, ScoreConfigsClient from .scores.client import AsyncScoresClient, ScoresClient - from .scores_v3.client import AsyncScoresV3Client, ScoresV3Client from .trace.client import AsyncTraceClient, TraceClient from .unstable.client import AsyncUnstableClient, UnstableClient @@ -149,7 +148,6 @@ def __init__( self._prompts: typing.Optional[PromptsClient] = None self._scim: typing.Optional[ScimClient] = None self._score_configs: typing.Optional[ScoreConfigsClient] = None - self._scores_v3: typing.Optional[ScoresV3Client] = None self._scores: typing.Optional[ScoresClient] = None self._trace: typing.Optional[TraceClient] = None self._unstable: typing.Optional[UnstableClient] = None @@ -356,14 +354,6 @@ def score_configs(self): ) return self._score_configs - @property - def scores_v3(self): - if self._scores_v3 is None: - from .scores_v3.client import ScoresV3Client # noqa: E402 - - self._scores_v3 = ScoresV3Client(client_wrapper=self._client_wrapper) - return self._scores_v3 - @property def scores(self): if self._scores is None: @@ -492,7 +482,6 @@ def __init__( self._prompts: typing.Optional[AsyncPromptsClient] = None self._scim: typing.Optional[AsyncScimClient] = None self._score_configs: typing.Optional[AsyncScoreConfigsClient] = None - self._scores_v3: typing.Optional[AsyncScoresV3Client] = None self._scores: typing.Optional[AsyncScoresClient] = None self._trace: typing.Optional[AsyncTraceClient] = None self._unstable: typing.Optional[AsyncUnstableClient] = None @@ -707,14 +696,6 @@ def score_configs(self): ) return self._score_configs - @property - def scores_v3(self): - if self._scores_v3 is None: - from .scores_v3.client import AsyncScoresV3Client # noqa: E402 - - self._scores_v3 = AsyncScoresV3Client(client_wrapper=self._client_wrapper) - return self._scores_v3 - @property def scores(self): if self._scores is None: diff --git a/langfuse/api/experiments/types/experiment.py b/langfuse/api/experiments/types/experiment.py index e52022b68..77118b4c3 100644 --- a/langfuse/api/experiments/types/experiment.py +++ b/langfuse/api/experiments/types/experiment.py @@ -7,7 +7,7 @@ import typing_extensions from ...core.pydantic_utilities import UniversalBaseModel from ...core.serialization import FieldMetadata -from ...scores_v3.types.score_v3 import ScoreV3 +from ...scores.types.score_v3 import ScoreV3 class Experiment(UniversalBaseModel): diff --git a/langfuse/api/experiments/types/experiment_item.py b/langfuse/api/experiments/types/experiment_item.py index a294c8eae..3850750e9 100644 --- a/langfuse/api/experiments/types/experiment_item.py +++ b/langfuse/api/experiments/types/experiment_item.py @@ -8,7 +8,7 @@ from ...commons.types.observation_level import ObservationLevel from ...core.pydantic_utilities import UniversalBaseModel from ...core.serialization import FieldMetadata -from ...scores_v3.types.score_v3 import ScoreV3 +from ...scores.types.score_v3 import ScoreV3 class ExperimentItem(UniversalBaseModel): diff --git a/langfuse/api/scores/__init__.py b/langfuse/api/scores/__init__.py index 5af5bdfb8..d0251574f 100644 --- a/langfuse/api/scores/__init__.py +++ b/langfuse/api/scores/__init__.py @@ -7,6 +7,10 @@ if typing.TYPE_CHECKING: from .types import ( + BaseScoreV3, + BooleanScoreV3, + CategoricalScoreV3, + CorrectionScoreV3, CreateScoreBatchError, CreateScoreBatchRequest, CreateScoreBatchResponse, @@ -16,8 +20,31 @@ CreateScoreSource, CreateScoresRequest, CreateScoresResponse, + GetScoresV3Meta, + GetScoresV3Response, + NumericScoreV3, + ScoreSubjectExperimentV3, + ScoreSubjectObservationV3, + ScoreSubjectSessionV3, + ScoreSubjectTraceV3, + ScoreSubjectV3, + ScoreSubjectV3_Experiment, + ScoreSubjectV3_Observation, + ScoreSubjectV3_Session, + ScoreSubjectV3_Trace, + ScoreV3, + ScoreV3_Boolean, + ScoreV3_Categorical, + ScoreV3_Correction, + ScoreV3_Numeric, + ScoreV3_Text, + TextScoreV3, ) _dynamic_imports: typing.Dict[str, str] = { + "BaseScoreV3": ".types", + "BooleanScoreV3": ".types", + "CategoricalScoreV3": ".types", + "CorrectionScoreV3": ".types", "CreateScoreBatchError": ".types", "CreateScoreBatchRequest": ".types", "CreateScoreBatchResponse": ".types", @@ -27,6 +54,25 @@ "CreateScoreSource": ".types", "CreateScoresRequest": ".types", "CreateScoresResponse": ".types", + "GetScoresV3Meta": ".types", + "GetScoresV3Response": ".types", + "NumericScoreV3": ".types", + "ScoreSubjectExperimentV3": ".types", + "ScoreSubjectObservationV3": ".types", + "ScoreSubjectSessionV3": ".types", + "ScoreSubjectTraceV3": ".types", + "ScoreSubjectV3": ".types", + "ScoreSubjectV3_Experiment": ".types", + "ScoreSubjectV3_Observation": ".types", + "ScoreSubjectV3_Session": ".types", + "ScoreSubjectV3_Trace": ".types", + "ScoreV3": ".types", + "ScoreV3_Boolean": ".types", + "ScoreV3_Categorical": ".types", + "ScoreV3_Correction": ".types", + "ScoreV3_Numeric": ".types", + "ScoreV3_Text": ".types", + "TextScoreV3": ".types", } @@ -58,6 +104,10 @@ def __dir__(): __all__ = [ + "BaseScoreV3", + "BooleanScoreV3", + "CategoricalScoreV3", + "CorrectionScoreV3", "CreateScoreBatchError", "CreateScoreBatchRequest", "CreateScoreBatchResponse", @@ -67,4 +117,23 @@ def __dir__(): "CreateScoreSource", "CreateScoresRequest", "CreateScoresResponse", + "GetScoresV3Meta", + "GetScoresV3Response", + "NumericScoreV3", + "ScoreSubjectExperimentV3", + "ScoreSubjectObservationV3", + "ScoreSubjectSessionV3", + "ScoreSubjectTraceV3", + "ScoreSubjectV3", + "ScoreSubjectV3_Experiment", + "ScoreSubjectV3_Observation", + "ScoreSubjectV3_Session", + "ScoreSubjectV3_Trace", + "ScoreV3", + "ScoreV3_Boolean", + "ScoreV3_Categorical", + "ScoreV3_Correction", + "ScoreV3_Numeric", + "ScoreV3_Text", + "TextScoreV3", ] diff --git a/langfuse/api/scores/client.py b/langfuse/api/scores/client.py index 3690d8b57..feac3cbca 100644 --- a/langfuse/api/scores/client.py +++ b/langfuse/api/scores/client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper @@ -7,6 +8,7 @@ from .raw_client import AsyncRawScoresClient, RawScoresClient from .types.create_scores_request import CreateScoresRequest from .types.create_scores_response import CreateScoresResponse +from .types.get_scores_v3response import GetScoresV3Response # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -109,6 +111,159 @@ def delete( _response = self._raw_client.delete(score_id, request_options=request_options) return _response.data + def get_many( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + fields: typing.Optional[str] = None, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + source: typing.Optional[str] = None, + data_type: typing.Optional[str] = None, + environment: typing.Optional[str] = None, + config_id: typing.Optional[str] = None, + queue_id: typing.Optional[str] = None, + author_user_id: typing.Optional[str] = None, + value: typing.Optional[str] = None, + value_min: typing.Optional[float] = None, + value_max: typing.Optional[float] = None, + trace_id: typing.Optional[str] = None, + session_id: typing.Optional[str] = None, + observation_id: typing.Optional[str] = None, + experiment_id: typing.Optional[str] = None, + from_timestamp: typing.Optional[dt.datetime] = None, + to_timestamp: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> GetScoresV3Response: + """ + Get a list of scores with a polymorphic `value` field (v3). + + The `value` field type depends on `dataType`: + - `NUMERIC` → number + - `BOOLEAN` → boolean + - `CATEGORICAL`, `TEXT`, `CORRECTION` → string + + The response always includes the core fields: id, projectId, name, + value, dataType, source, timestamp, environment, createdAt, updatedAt. + + Additional field groups can be requested via the `fields` parameter: + - `details` — adds comment, configId, metadata + - `subject` — adds the subject object describing the entity the score + is attached to: kind (trace, observation, session, or experiment), + id, and traceId for observation-level scores + - `annotation` — adds authorUserId, queueId + + Unknown group names return HTTP 400. + + Parameters + ---------- + limit : typing.Optional[int] + Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. + + cursor : typing.Optional[str] + URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. + + fields : typing.Optional[str] + Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. + + id : typing.Optional[str] + Comma-separated list of score IDs to filter by (OR within, AND across filters). + + name : typing.Optional[str] + Comma-separated list of score names to filter by. + + source : typing.Optional[str] + Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. + + data_type : typing.Optional[str] + Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. + + environment : typing.Optional[str] + Comma-separated list of environments to filter by. + + config_id : typing.Optional[str] + Comma-separated list of score config IDs to filter by. + + queue_id : typing.Optional[str] + Comma-separated list of annotation queue IDs to filter by. + + author_user_id : typing.Optional[str] + Comma-separated list of author user IDs to filter by. + + value : typing.Optional[str] + Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. + + value_min : typing.Optional[float] + Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + value_max : typing.Optional[float] + Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + trace_id : typing.Optional[str] + Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. + + session_id : typing.Optional[str] + Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. + + observation_id : typing.Optional[str] + Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. + + experiment_id : typing.Optional[str] + Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. + + from_timestamp : typing.Optional[dt.datetime] + Inclusive lower bound on the score timestamp. + + to_timestamp : typing.Optional[dt.datetime] + Exclusive upper bound on the score timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetScoresV3Response + + Examples + -------- + from langfuse import LangfuseAPI + + client = LangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + client.scores.get_many() + """ + _response = self._raw_client.get_many( + limit=limit, + cursor=cursor, + fields=fields, + id=id, + name=name, + source=source, + data_type=data_type, + environment=environment, + config_id=config_id, + queue_id=queue_id, + author_user_id=author_user_id, + value=value, + value_min=value_min, + value_max=value_max, + trace_id=trace_id, + session_id=session_id, + observation_id=observation_id, + experiment_id=experiment_id, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + request_options=request_options, + ) + return _response.data + class AsyncScoresClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -224,3 +379,164 @@ async def main() -> None: score_id, request_options=request_options ) return _response.data + + async def get_many( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + fields: typing.Optional[str] = None, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + source: typing.Optional[str] = None, + data_type: typing.Optional[str] = None, + environment: typing.Optional[str] = None, + config_id: typing.Optional[str] = None, + queue_id: typing.Optional[str] = None, + author_user_id: typing.Optional[str] = None, + value: typing.Optional[str] = None, + value_min: typing.Optional[float] = None, + value_max: typing.Optional[float] = None, + trace_id: typing.Optional[str] = None, + session_id: typing.Optional[str] = None, + observation_id: typing.Optional[str] = None, + experiment_id: typing.Optional[str] = None, + from_timestamp: typing.Optional[dt.datetime] = None, + to_timestamp: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> GetScoresV3Response: + """ + Get a list of scores with a polymorphic `value` field (v3). + + The `value` field type depends on `dataType`: + - `NUMERIC` → number + - `BOOLEAN` → boolean + - `CATEGORICAL`, `TEXT`, `CORRECTION` → string + + The response always includes the core fields: id, projectId, name, + value, dataType, source, timestamp, environment, createdAt, updatedAt. + + Additional field groups can be requested via the `fields` parameter: + - `details` — adds comment, configId, metadata + - `subject` — adds the subject object describing the entity the score + is attached to: kind (trace, observation, session, or experiment), + id, and traceId for observation-level scores + - `annotation` — adds authorUserId, queueId + + Unknown group names return HTTP 400. + + Parameters + ---------- + limit : typing.Optional[int] + Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. + + cursor : typing.Optional[str] + URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. + + fields : typing.Optional[str] + Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. + + id : typing.Optional[str] + Comma-separated list of score IDs to filter by (OR within, AND across filters). + + name : typing.Optional[str] + Comma-separated list of score names to filter by. + + source : typing.Optional[str] + Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. + + data_type : typing.Optional[str] + Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. + + environment : typing.Optional[str] + Comma-separated list of environments to filter by. + + config_id : typing.Optional[str] + Comma-separated list of score config IDs to filter by. + + queue_id : typing.Optional[str] + Comma-separated list of annotation queue IDs to filter by. + + author_user_id : typing.Optional[str] + Comma-separated list of author user IDs to filter by. + + value : typing.Optional[str] + Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. + + value_min : typing.Optional[float] + Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + value_max : typing.Optional[float] + Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + trace_id : typing.Optional[str] + Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. + + session_id : typing.Optional[str] + Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. + + observation_id : typing.Optional[str] + Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. + + experiment_id : typing.Optional[str] + Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. + + from_timestamp : typing.Optional[dt.datetime] + Inclusive lower bound on the score timestamp. + + to_timestamp : typing.Optional[dt.datetime] + Exclusive upper bound on the score timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetScoresV3Response + + Examples + -------- + import asyncio + + from langfuse import AsyncLangfuseAPI + + client = AsyncLangfuseAPI( + x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", + x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", + x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", + username="YOUR_USERNAME", + password="YOUR_PASSWORD", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.scores.get_many() + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_many( + limit=limit, + cursor=cursor, + fields=fields, + id=id, + name=name, + source=source, + data_type=data_type, + environment=environment, + config_id=config_id, + queue_id=queue_id, + author_user_id=author_user_id, + value=value, + value_min=value_min, + value_max=value_max, + trace_id=trace_id, + session_id=session_id, + observation_id=observation_id, + experiment_id=experiment_id, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + request_options=request_options, + ) + return _response.data diff --git a/langfuse/api/scores/raw_client.py b/langfuse/api/scores/raw_client.py index f9f75efc9..d0cb9663c 100644 --- a/langfuse/api/scores/raw_client.py +++ b/langfuse/api/scores/raw_client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from json.decoder import JSONDecodeError @@ -10,6 +11,7 @@ from ..commons.errors.unauthorized_error import UnauthorizedError from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import jsonable_encoder from ..core.pydantic_utilities import parse_obj_as @@ -17,6 +19,7 @@ from ..core.serialization import convert_and_respect_annotation_metadata from .types.create_scores_request import CreateScoresRequest from .types.create_scores_response import CreateScoresResponse +from .types.get_scores_v3response import GetScoresV3Response # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -227,6 +230,229 @@ def delete( body=_response_json, ) + def get_many( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + fields: typing.Optional[str] = None, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + source: typing.Optional[str] = None, + data_type: typing.Optional[str] = None, + environment: typing.Optional[str] = None, + config_id: typing.Optional[str] = None, + queue_id: typing.Optional[str] = None, + author_user_id: typing.Optional[str] = None, + value: typing.Optional[str] = None, + value_min: typing.Optional[float] = None, + value_max: typing.Optional[float] = None, + trace_id: typing.Optional[str] = None, + session_id: typing.Optional[str] = None, + observation_id: typing.Optional[str] = None, + experiment_id: typing.Optional[str] = None, + from_timestamp: typing.Optional[dt.datetime] = None, + to_timestamp: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[GetScoresV3Response]: + """ + Get a list of scores with a polymorphic `value` field (v3). + + The `value` field type depends on `dataType`: + - `NUMERIC` → number + - `BOOLEAN` → boolean + - `CATEGORICAL`, `TEXT`, `CORRECTION` → string + + The response always includes the core fields: id, projectId, name, + value, dataType, source, timestamp, environment, createdAt, updatedAt. + + Additional field groups can be requested via the `fields` parameter: + - `details` — adds comment, configId, metadata + - `subject` — adds the subject object describing the entity the score + is attached to: kind (trace, observation, session, or experiment), + id, and traceId for observation-level scores + - `annotation` — adds authorUserId, queueId + + Unknown group names return HTTP 400. + + Parameters + ---------- + limit : typing.Optional[int] + Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. + + cursor : typing.Optional[str] + URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. + + fields : typing.Optional[str] + Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. + + id : typing.Optional[str] + Comma-separated list of score IDs to filter by (OR within, AND across filters). + + name : typing.Optional[str] + Comma-separated list of score names to filter by. + + source : typing.Optional[str] + Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. + + data_type : typing.Optional[str] + Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. + + environment : typing.Optional[str] + Comma-separated list of environments to filter by. + + config_id : typing.Optional[str] + Comma-separated list of score config IDs to filter by. + + queue_id : typing.Optional[str] + Comma-separated list of annotation queue IDs to filter by. + + author_user_id : typing.Optional[str] + Comma-separated list of author user IDs to filter by. + + value : typing.Optional[str] + Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. + + value_min : typing.Optional[float] + Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + value_max : typing.Optional[float] + Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + trace_id : typing.Optional[str] + Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. + + session_id : typing.Optional[str] + Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. + + observation_id : typing.Optional[str] + Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. + + experiment_id : typing.Optional[str] + Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. + + from_timestamp : typing.Optional[dt.datetime] + Inclusive lower bound on the score timestamp. + + to_timestamp : typing.Optional[dt.datetime] + Exclusive upper bound on the score timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[GetScoresV3Response] + """ + _response = self._client_wrapper.httpx_client.request( + "api/public/v3/scores", + method="GET", + params={ + "limit": limit, + "cursor": cursor, + "fields": fields, + "id": id, + "name": name, + "source": source, + "dataType": data_type, + "environment": environment, + "configId": config_id, + "queueId": queue_id, + "authorUserId": author_user_id, + "value": value, + "valueMin": value_min, + "valueMax": value_max, + "traceId": trace_id, + "sessionId": session_id, + "observationId": observation_id, + "experimentId": experiment_id, + "fromTimestamp": serialize_datetime(from_timestamp) + if from_timestamp is not None + else None, + "toTimestamp": serialize_datetime(to_timestamp) + if to_timestamp is not None + else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetScoresV3Response, + parse_obj_as( + type_=GetScoresV3Response, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + class AsyncRawScoresClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -432,3 +658,226 @@ async def delete( headers=dict(_response.headers), body=_response_json, ) + + async def get_many( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + fields: typing.Optional[str] = None, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + source: typing.Optional[str] = None, + data_type: typing.Optional[str] = None, + environment: typing.Optional[str] = None, + config_id: typing.Optional[str] = None, + queue_id: typing.Optional[str] = None, + author_user_id: typing.Optional[str] = None, + value: typing.Optional[str] = None, + value_min: typing.Optional[float] = None, + value_max: typing.Optional[float] = None, + trace_id: typing.Optional[str] = None, + session_id: typing.Optional[str] = None, + observation_id: typing.Optional[str] = None, + experiment_id: typing.Optional[str] = None, + from_timestamp: typing.Optional[dt.datetime] = None, + to_timestamp: typing.Optional[dt.datetime] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[GetScoresV3Response]: + """ + Get a list of scores with a polymorphic `value` field (v3). + + The `value` field type depends on `dataType`: + - `NUMERIC` → number + - `BOOLEAN` → boolean + - `CATEGORICAL`, `TEXT`, `CORRECTION` → string + + The response always includes the core fields: id, projectId, name, + value, dataType, source, timestamp, environment, createdAt, updatedAt. + + Additional field groups can be requested via the `fields` parameter: + - `details` — adds comment, configId, metadata + - `subject` — adds the subject object describing the entity the score + is attached to: kind (trace, observation, session, or experiment), + id, and traceId for observation-level scores + - `annotation` — adds authorUserId, queueId + + Unknown group names return HTTP 400. + + Parameters + ---------- + limit : typing.Optional[int] + Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. + + cursor : typing.Optional[str] + URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. + + fields : typing.Optional[str] + Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. + + id : typing.Optional[str] + Comma-separated list of score IDs to filter by (OR within, AND across filters). + + name : typing.Optional[str] + Comma-separated list of score names to filter by. + + source : typing.Optional[str] + Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. + + data_type : typing.Optional[str] + Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. + + environment : typing.Optional[str] + Comma-separated list of environments to filter by. + + config_id : typing.Optional[str] + Comma-separated list of score config IDs to filter by. + + queue_id : typing.Optional[str] + Comma-separated list of annotation queue IDs to filter by. + + author_user_id : typing.Optional[str] + Comma-separated list of author user IDs to filter by. + + value : typing.Optional[str] + Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. + + value_min : typing.Optional[float] + Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + value_max : typing.Optional[float] + Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. + + trace_id : typing.Optional[str] + Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. + + session_id : typing.Optional[str] + Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. + + observation_id : typing.Optional[str] + Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. + + experiment_id : typing.Optional[str] + Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. + + from_timestamp : typing.Optional[dt.datetime] + Inclusive lower bound on the score timestamp. + + to_timestamp : typing.Optional[dt.datetime] + Exclusive upper bound on the score timestamp. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[GetScoresV3Response] + """ + _response = await self._client_wrapper.httpx_client.request( + "api/public/v3/scores", + method="GET", + params={ + "limit": limit, + "cursor": cursor, + "fields": fields, + "id": id, + "name": name, + "source": source, + "dataType": data_type, + "environment": environment, + "configId": config_id, + "queueId": queue_id, + "authorUserId": author_user_id, + "value": value, + "valueMin": value_min, + "valueMax": value_max, + "traceId": trace_id, + "sessionId": session_id, + "observationId": observation_id, + "experimentId": experiment_id, + "fromTimestamp": serialize_datetime(from_timestamp) + if from_timestamp is not None + else None, + "toTimestamp": serialize_datetime(to_timestamp) + if to_timestamp is not None + else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetScoresV3Response, + parse_obj_as( + type_=GetScoresV3Response, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise Error( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise AccessDeniedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) diff --git a/langfuse/api/scores/types/__init__.py b/langfuse/api/scores/types/__init__.py index 067d8fb27..ae28d182b 100644 --- a/langfuse/api/scores/types/__init__.py +++ b/langfuse/api/scores/types/__init__.py @@ -6,6 +6,10 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .base_score_v3 import BaseScoreV3 + from .boolean_score_v3 import BooleanScoreV3 + from .categorical_score_v3 import CategoricalScoreV3 + from .correction_score_v3 import CorrectionScoreV3 from .create_score_batch_error import CreateScoreBatchError from .create_score_batch_request import CreateScoreBatchRequest from .create_score_batch_response import CreateScoreBatchResponse @@ -15,7 +19,34 @@ from .create_score_source import CreateScoreSource from .create_scores_request import CreateScoresRequest from .create_scores_response import CreateScoresResponse + from .get_scores_v3meta import GetScoresV3Meta + from .get_scores_v3response import GetScoresV3Response + from .numeric_score_v3 import NumericScoreV3 + from .score_subject_experiment_v3 import ScoreSubjectExperimentV3 + from .score_subject_observation_v3 import ScoreSubjectObservationV3 + from .score_subject_session_v3 import ScoreSubjectSessionV3 + from .score_subject_trace_v3 import ScoreSubjectTraceV3 + from .score_subject_v3 import ( + ScoreSubjectV3, + ScoreSubjectV3_Experiment, + ScoreSubjectV3_Observation, + ScoreSubjectV3_Session, + ScoreSubjectV3_Trace, + ) + from .score_v3 import ( + ScoreV3, + ScoreV3_Boolean, + ScoreV3_Categorical, + ScoreV3_Correction, + ScoreV3_Numeric, + ScoreV3_Text, + ) + from .text_score_v3 import TextScoreV3 _dynamic_imports: typing.Dict[str, str] = { + "BaseScoreV3": ".base_score_v3", + "BooleanScoreV3": ".boolean_score_v3", + "CategoricalScoreV3": ".categorical_score_v3", + "CorrectionScoreV3": ".correction_score_v3", "CreateScoreBatchError": ".create_score_batch_error", "CreateScoreBatchRequest": ".create_score_batch_request", "CreateScoreBatchResponse": ".create_score_batch_response", @@ -25,6 +56,25 @@ "CreateScoreSource": ".create_score_source", "CreateScoresRequest": ".create_scores_request", "CreateScoresResponse": ".create_scores_response", + "GetScoresV3Meta": ".get_scores_v3meta", + "GetScoresV3Response": ".get_scores_v3response", + "NumericScoreV3": ".numeric_score_v3", + "ScoreSubjectExperimentV3": ".score_subject_experiment_v3", + "ScoreSubjectObservationV3": ".score_subject_observation_v3", + "ScoreSubjectSessionV3": ".score_subject_session_v3", + "ScoreSubjectTraceV3": ".score_subject_trace_v3", + "ScoreSubjectV3": ".score_subject_v3", + "ScoreSubjectV3_Experiment": ".score_subject_v3", + "ScoreSubjectV3_Observation": ".score_subject_v3", + "ScoreSubjectV3_Session": ".score_subject_v3", + "ScoreSubjectV3_Trace": ".score_subject_v3", + "ScoreV3": ".score_v3", + "ScoreV3_Boolean": ".score_v3", + "ScoreV3_Categorical": ".score_v3", + "ScoreV3_Correction": ".score_v3", + "ScoreV3_Numeric": ".score_v3", + "ScoreV3_Text": ".score_v3", + "TextScoreV3": ".text_score_v3", } @@ -56,6 +106,10 @@ def __dir__(): __all__ = [ + "BaseScoreV3", + "BooleanScoreV3", + "CategoricalScoreV3", + "CorrectionScoreV3", "CreateScoreBatchError", "CreateScoreBatchRequest", "CreateScoreBatchResponse", @@ -65,4 +119,23 @@ def __dir__(): "CreateScoreSource", "CreateScoresRequest", "CreateScoresResponse", + "GetScoresV3Meta", + "GetScoresV3Response", + "NumericScoreV3", + "ScoreSubjectExperimentV3", + "ScoreSubjectObservationV3", + "ScoreSubjectSessionV3", + "ScoreSubjectTraceV3", + "ScoreSubjectV3", + "ScoreSubjectV3_Experiment", + "ScoreSubjectV3_Observation", + "ScoreSubjectV3_Session", + "ScoreSubjectV3_Trace", + "ScoreV3", + "ScoreV3_Boolean", + "ScoreV3_Categorical", + "ScoreV3_Correction", + "ScoreV3_Numeric", + "ScoreV3_Text", + "TextScoreV3", ] diff --git a/langfuse/api/scores_v3/types/base_score_v3.py b/langfuse/api/scores/types/base_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/base_score_v3.py rename to langfuse/api/scores/types/base_score_v3.py diff --git a/langfuse/api/scores_v3/types/boolean_score_v3.py b/langfuse/api/scores/types/boolean_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/boolean_score_v3.py rename to langfuse/api/scores/types/boolean_score_v3.py diff --git a/langfuse/api/scores_v3/types/categorical_score_v3.py b/langfuse/api/scores/types/categorical_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/categorical_score_v3.py rename to langfuse/api/scores/types/categorical_score_v3.py diff --git a/langfuse/api/scores_v3/types/correction_score_v3.py b/langfuse/api/scores/types/correction_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/correction_score_v3.py rename to langfuse/api/scores/types/correction_score_v3.py diff --git a/langfuse/api/scores_v3/types/get_scores_v3meta.py b/langfuse/api/scores/types/get_scores_v3meta.py similarity index 100% rename from langfuse/api/scores_v3/types/get_scores_v3meta.py rename to langfuse/api/scores/types/get_scores_v3meta.py diff --git a/langfuse/api/scores_v3/types/get_scores_v3response.py b/langfuse/api/scores/types/get_scores_v3response.py similarity index 100% rename from langfuse/api/scores_v3/types/get_scores_v3response.py rename to langfuse/api/scores/types/get_scores_v3response.py diff --git a/langfuse/api/scores_v3/types/numeric_score_v3.py b/langfuse/api/scores/types/numeric_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/numeric_score_v3.py rename to langfuse/api/scores/types/numeric_score_v3.py diff --git a/langfuse/api/scores_v3/types/score_subject_experiment_v3.py b/langfuse/api/scores/types/score_subject_experiment_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_subject_experiment_v3.py rename to langfuse/api/scores/types/score_subject_experiment_v3.py diff --git a/langfuse/api/scores_v3/types/score_subject_observation_v3.py b/langfuse/api/scores/types/score_subject_observation_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_subject_observation_v3.py rename to langfuse/api/scores/types/score_subject_observation_v3.py diff --git a/langfuse/api/scores_v3/types/score_subject_session_v3.py b/langfuse/api/scores/types/score_subject_session_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_subject_session_v3.py rename to langfuse/api/scores/types/score_subject_session_v3.py diff --git a/langfuse/api/scores_v3/types/score_subject_trace_v3.py b/langfuse/api/scores/types/score_subject_trace_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_subject_trace_v3.py rename to langfuse/api/scores/types/score_subject_trace_v3.py diff --git a/langfuse/api/scores_v3/types/score_subject_v3.py b/langfuse/api/scores/types/score_subject_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_subject_v3.py rename to langfuse/api/scores/types/score_subject_v3.py diff --git a/langfuse/api/scores_v3/types/score_v3.py b/langfuse/api/scores/types/score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/score_v3.py rename to langfuse/api/scores/types/score_v3.py diff --git a/langfuse/api/scores_v3/types/text_score_v3.py b/langfuse/api/scores/types/text_score_v3.py similarity index 100% rename from langfuse/api/scores_v3/types/text_score_v3.py rename to langfuse/api/scores/types/text_score_v3.py diff --git a/langfuse/api/scores_v3/__init__.py b/langfuse/api/scores_v3/__init__.py deleted file mode 100644 index 855868335..000000000 --- a/langfuse/api/scores_v3/__init__.py +++ /dev/null @@ -1,112 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import ( - BaseScoreV3, - BooleanScoreV3, - CategoricalScoreV3, - CorrectionScoreV3, - GetScoresV3Meta, - GetScoresV3Response, - NumericScoreV3, - ScoreSubjectExperimentV3, - ScoreSubjectObservationV3, - ScoreSubjectSessionV3, - ScoreSubjectTraceV3, - ScoreSubjectV3, - ScoreSubjectV3_Experiment, - ScoreSubjectV3_Observation, - ScoreSubjectV3_Session, - ScoreSubjectV3_Trace, - ScoreV3, - ScoreV3_Boolean, - ScoreV3_Categorical, - ScoreV3_Correction, - ScoreV3_Numeric, - ScoreV3_Text, - TextScoreV3, - ) -_dynamic_imports: typing.Dict[str, str] = { - "BaseScoreV3": ".types", - "BooleanScoreV3": ".types", - "CategoricalScoreV3": ".types", - "CorrectionScoreV3": ".types", - "GetScoresV3Meta": ".types", - "GetScoresV3Response": ".types", - "NumericScoreV3": ".types", - "ScoreSubjectExperimentV3": ".types", - "ScoreSubjectObservationV3": ".types", - "ScoreSubjectSessionV3": ".types", - "ScoreSubjectTraceV3": ".types", - "ScoreSubjectV3": ".types", - "ScoreSubjectV3_Experiment": ".types", - "ScoreSubjectV3_Observation": ".types", - "ScoreSubjectV3_Session": ".types", - "ScoreSubjectV3_Trace": ".types", - "ScoreV3": ".types", - "ScoreV3_Boolean": ".types", - "ScoreV3_Categorical": ".types", - "ScoreV3_Correction": ".types", - "ScoreV3_Numeric": ".types", - "ScoreV3_Text": ".types", - "TextScoreV3": ".types", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = [ - "BaseScoreV3", - "BooleanScoreV3", - "CategoricalScoreV3", - "CorrectionScoreV3", - "GetScoresV3Meta", - "GetScoresV3Response", - "NumericScoreV3", - "ScoreSubjectExperimentV3", - "ScoreSubjectObservationV3", - "ScoreSubjectSessionV3", - "ScoreSubjectTraceV3", - "ScoreSubjectV3", - "ScoreSubjectV3_Experiment", - "ScoreSubjectV3_Observation", - "ScoreSubjectV3_Session", - "ScoreSubjectV3_Trace", - "ScoreV3", - "ScoreV3_Boolean", - "ScoreV3_Categorical", - "ScoreV3_Correction", - "ScoreV3_Numeric", - "ScoreV3_Text", - "TextScoreV3", -] diff --git a/langfuse/api/scores_v3/client.py b/langfuse/api/scores_v3/client.py deleted file mode 100644 index d5afda7dd..000000000 --- a/langfuse/api/scores_v3/client.py +++ /dev/null @@ -1,355 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.request_options import RequestOptions -from .raw_client import AsyncRawScoresV3Client, RawScoresV3Client -from .types.get_scores_v3response import GetScoresV3Response - - -class ScoresV3Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawScoresV3Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawScoresV3Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawScoresV3Client - """ - return self._raw_client - - def get_many_v3( - self, - *, - limit: typing.Optional[int] = None, - cursor: typing.Optional[str] = None, - fields: typing.Optional[str] = None, - id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - source: typing.Optional[str] = None, - data_type: typing.Optional[str] = None, - environment: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - author_user_id: typing.Optional[str] = None, - value: typing.Optional[str] = None, - value_min: typing.Optional[float] = None, - value_max: typing.Optional[float] = None, - trace_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - experiment_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> GetScoresV3Response: - """ - Get a list of scores with a polymorphic `value` field (v3). - - The `value` field type depends on `dataType`: - - `NUMERIC` → number - - `BOOLEAN` → boolean - - `CATEGORICAL`, `TEXT`, `CORRECTION` → string - - The response always includes the core fields: id, projectId, name, - value, dataType, source, timestamp, environment, createdAt, updatedAt. - - Additional field groups can be requested via the `fields` parameter: - - `details` — adds comment, configId, metadata - - `subject` — adds the subject object describing the entity the score - is attached to: kind (trace, observation, session, or experiment), - id, and traceId for observation-level scores - - `annotation` — adds authorUserId, queueId - - Unknown group names return HTTP 400. - - Parameters - ---------- - limit : typing.Optional[int] - Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. - - cursor : typing.Optional[str] - URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. - - fields : typing.Optional[str] - Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. - - id : typing.Optional[str] - Comma-separated list of score IDs to filter by (OR within, AND across filters). - - name : typing.Optional[str] - Comma-separated list of score names to filter by. - - source : typing.Optional[str] - Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. - - data_type : typing.Optional[str] - Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. - - environment : typing.Optional[str] - Comma-separated list of environments to filter by. - - config_id : typing.Optional[str] - Comma-separated list of score config IDs to filter by. - - queue_id : typing.Optional[str] - Comma-separated list of annotation queue IDs to filter by. - - author_user_id : typing.Optional[str] - Comma-separated list of author user IDs to filter by. - - value : typing.Optional[str] - Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. - - value_min : typing.Optional[float] - Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - value_max : typing.Optional[float] - Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - trace_id : typing.Optional[str] - Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. - - session_id : typing.Optional[str] - Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. - - experiment_id : typing.Optional[str] - Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. - - from_timestamp : typing.Optional[dt.datetime] - Inclusive lower bound on the score timestamp. - - to_timestamp : typing.Optional[dt.datetime] - Exclusive upper bound on the score timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - GetScoresV3Response - - Examples - -------- - from langfuse import LangfuseAPI - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.scores_v3.get_many_v3() - """ - _response = self._raw_client.get_many_v3( - limit=limit, - cursor=cursor, - fields=fields, - id=id, - name=name, - source=source, - data_type=data_type, - environment=environment, - config_id=config_id, - queue_id=queue_id, - author_user_id=author_user_id, - value=value, - value_min=value_min, - value_max=value_max, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - experiment_id=experiment_id, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - request_options=request_options, - ) - return _response.data - - -class AsyncScoresV3Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawScoresV3Client(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawScoresV3Client: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawScoresV3Client - """ - return self._raw_client - - async def get_many_v3( - self, - *, - limit: typing.Optional[int] = None, - cursor: typing.Optional[str] = None, - fields: typing.Optional[str] = None, - id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - source: typing.Optional[str] = None, - data_type: typing.Optional[str] = None, - environment: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - author_user_id: typing.Optional[str] = None, - value: typing.Optional[str] = None, - value_min: typing.Optional[float] = None, - value_max: typing.Optional[float] = None, - trace_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - experiment_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> GetScoresV3Response: - """ - Get a list of scores with a polymorphic `value` field (v3). - - The `value` field type depends on `dataType`: - - `NUMERIC` → number - - `BOOLEAN` → boolean - - `CATEGORICAL`, `TEXT`, `CORRECTION` → string - - The response always includes the core fields: id, projectId, name, - value, dataType, source, timestamp, environment, createdAt, updatedAt. - - Additional field groups can be requested via the `fields` parameter: - - `details` — adds comment, configId, metadata - - `subject` — adds the subject object describing the entity the score - is attached to: kind (trace, observation, session, or experiment), - id, and traceId for observation-level scores - - `annotation` — adds authorUserId, queueId - - Unknown group names return HTTP 400. - - Parameters - ---------- - limit : typing.Optional[int] - Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. - - cursor : typing.Optional[str] - URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. - - fields : typing.Optional[str] - Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. - - id : typing.Optional[str] - Comma-separated list of score IDs to filter by (OR within, AND across filters). - - name : typing.Optional[str] - Comma-separated list of score names to filter by. - - source : typing.Optional[str] - Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. - - data_type : typing.Optional[str] - Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. - - environment : typing.Optional[str] - Comma-separated list of environments to filter by. - - config_id : typing.Optional[str] - Comma-separated list of score config IDs to filter by. - - queue_id : typing.Optional[str] - Comma-separated list of annotation queue IDs to filter by. - - author_user_id : typing.Optional[str] - Comma-separated list of author user IDs to filter by. - - value : typing.Optional[str] - Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. - - value_min : typing.Optional[float] - Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - value_max : typing.Optional[float] - Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - trace_id : typing.Optional[str] - Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. - - session_id : typing.Optional[str] - Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. - - experiment_id : typing.Optional[str] - Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. - - from_timestamp : typing.Optional[dt.datetime] - Inclusive lower bound on the score timestamp. - - to_timestamp : typing.Optional[dt.datetime] - Exclusive upper bound on the score timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - GetScoresV3Response - - Examples - -------- - import asyncio - - from langfuse import AsyncLangfuseAPI - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.scores_v3.get_many_v3() - - - asyncio.run(main()) - """ - _response = await self._raw_client.get_many_v3( - limit=limit, - cursor=cursor, - fields=fields, - id=id, - name=name, - source=source, - data_type=data_type, - environment=environment, - config_id=config_id, - queue_id=queue_id, - author_user_id=author_user_id, - value=value, - value_min=value_min, - value_max=value_max, - trace_id=trace_id, - session_id=session_id, - observation_id=observation_id, - experiment_id=experiment_id, - from_timestamp=from_timestamp, - to_timestamp=to_timestamp, - request_options=request_options, - ) - return _response.data diff --git a/langfuse/api/scores_v3/raw_client.py b/langfuse/api/scores_v3/raw_client.py deleted file mode 100644 index cc0793172..000000000 --- a/langfuse/api/scores_v3/raw_client.py +++ /dev/null @@ -1,474 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing -from json.decoder import JSONDecodeError - -from ..commons.errors.access_denied_error import AccessDeniedError -from ..commons.errors.error import Error -from ..commons.errors.method_not_allowed_error import MethodNotAllowedError -from ..commons.errors.not_found_error import NotFoundError -from ..commons.errors.unauthorized_error import UnauthorizedError -from ..core.api_error import ApiError -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime -from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.pydantic_utilities import parse_obj_as -from ..core.request_options import RequestOptions -from .types.get_scores_v3response import GetScoresV3Response - - -class RawScoresV3Client: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - def get_many_v3( - self, - *, - limit: typing.Optional[int] = None, - cursor: typing.Optional[str] = None, - fields: typing.Optional[str] = None, - id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - source: typing.Optional[str] = None, - data_type: typing.Optional[str] = None, - environment: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - author_user_id: typing.Optional[str] = None, - value: typing.Optional[str] = None, - value_min: typing.Optional[float] = None, - value_max: typing.Optional[float] = None, - trace_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - experiment_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[GetScoresV3Response]: - """ - Get a list of scores with a polymorphic `value` field (v3). - - The `value` field type depends on `dataType`: - - `NUMERIC` → number - - `BOOLEAN` → boolean - - `CATEGORICAL`, `TEXT`, `CORRECTION` → string - - The response always includes the core fields: id, projectId, name, - value, dataType, source, timestamp, environment, createdAt, updatedAt. - - Additional field groups can be requested via the `fields` parameter: - - `details` — adds comment, configId, metadata - - `subject` — adds the subject object describing the entity the score - is attached to: kind (trace, observation, session, or experiment), - id, and traceId for observation-level scores - - `annotation` — adds authorUserId, queueId - - Unknown group names return HTTP 400. - - Parameters - ---------- - limit : typing.Optional[int] - Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. - - cursor : typing.Optional[str] - URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. - - fields : typing.Optional[str] - Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. - - id : typing.Optional[str] - Comma-separated list of score IDs to filter by (OR within, AND across filters). - - name : typing.Optional[str] - Comma-separated list of score names to filter by. - - source : typing.Optional[str] - Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. - - data_type : typing.Optional[str] - Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. - - environment : typing.Optional[str] - Comma-separated list of environments to filter by. - - config_id : typing.Optional[str] - Comma-separated list of score config IDs to filter by. - - queue_id : typing.Optional[str] - Comma-separated list of annotation queue IDs to filter by. - - author_user_id : typing.Optional[str] - Comma-separated list of author user IDs to filter by. - - value : typing.Optional[str] - Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. - - value_min : typing.Optional[float] - Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - value_max : typing.Optional[float] - Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - trace_id : typing.Optional[str] - Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. - - session_id : typing.Optional[str] - Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. - - experiment_id : typing.Optional[str] - Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. - - from_timestamp : typing.Optional[dt.datetime] - Inclusive lower bound on the score timestamp. - - to_timestamp : typing.Optional[dt.datetime] - Exclusive upper bound on the score timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[GetScoresV3Response] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/v3/scores", - method="GET", - params={ - "limit": limit, - "cursor": cursor, - "fields": fields, - "id": id, - "name": name, - "source": source, - "dataType": data_type, - "environment": environment, - "configId": config_id, - "queueId": queue_id, - "authorUserId": author_user_id, - "value": value, - "valueMin": value_min, - "valueMax": value_max, - "traceId": trace_id, - "sessionId": session_id, - "observationId": observation_id, - "experimentId": experiment_id, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - GetScoresV3Response, - parse_obj_as( - type_=GetScoresV3Response, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawScoresV3Client: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - async def get_many_v3( - self, - *, - limit: typing.Optional[int] = None, - cursor: typing.Optional[str] = None, - fields: typing.Optional[str] = None, - id: typing.Optional[str] = None, - name: typing.Optional[str] = None, - source: typing.Optional[str] = None, - data_type: typing.Optional[str] = None, - environment: typing.Optional[str] = None, - config_id: typing.Optional[str] = None, - queue_id: typing.Optional[str] = None, - author_user_id: typing.Optional[str] = None, - value: typing.Optional[str] = None, - value_min: typing.Optional[float] = None, - value_max: typing.Optional[float] = None, - trace_id: typing.Optional[str] = None, - session_id: typing.Optional[str] = None, - observation_id: typing.Optional[str] = None, - experiment_id: typing.Optional[str] = None, - from_timestamp: typing.Optional[dt.datetime] = None, - to_timestamp: typing.Optional[dt.datetime] = None, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[GetScoresV3Response]: - """ - Get a list of scores with a polymorphic `value` field (v3). - - The `value` field type depends on `dataType`: - - `NUMERIC` → number - - `BOOLEAN` → boolean - - `CATEGORICAL`, `TEXT`, `CORRECTION` → string - - The response always includes the core fields: id, projectId, name, - value, dataType, source, timestamp, environment, createdAt, updatedAt. - - Additional field groups can be requested via the `fields` parameter: - - `details` — adds comment, configId, metadata - - `subject` — adds the subject object describing the entity the score - is attached to: kind (trace, observation, session, or experiment), - id, and traceId for observation-level scores - - `annotation` — adds authorUserId, queueId - - Unknown group names return HTTP 400. - - Parameters - ---------- - limit : typing.Optional[int] - Number of items per page. Maximum 100, default 50. Requests with a limit greater than 100 return HTTP 400. - - cursor : typing.Optional[str] - URL-safe base64 (base64url) cursor for pagination. Use the cursor from the previous response to get the next page. Absent on the final page. - - fields : typing.Optional[str] - Comma-separated field groups to include in addition to the always-returned core fields. Allowed: details, subject, annotation — see the endpoint description for the fields each group adds. Unknown names return HTTP 400. - - id : typing.Optional[str] - Comma-separated list of score IDs to filter by (OR within, AND across filters). - - name : typing.Optional[str] - Comma-separated list of score names to filter by. - - source : typing.Optional[str] - Comma-separated list of score sources to filter by (e.g. API, ANNOTATION, EVAL). Case-insensitive — `api` and `API` are equivalent. - - data_type : typing.Optional[str] - Comma-separated list of data types to filter by (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION). Case-insensitive — `numeric` and `NUMERIC` are equivalent. Must be a single value when used with value, valueMin, or valueMax; otherwise the request returns HTTP 400. Must be NUMERIC when used with valueMin or valueMax. - - environment : typing.Optional[str] - Comma-separated list of environments to filter by. - - config_id : typing.Optional[str] - Comma-separated list of score config IDs to filter by. - - queue_id : typing.Optional[str] - Comma-separated list of annotation queue IDs to filter by. - - author_user_id : typing.Optional[str] - Comma-separated list of author user IDs to filter by. - - value : typing.Optional[str] - Comma-separated list of exact values to filter by. Requires a single dataType from NUMERIC, BOOLEAN, or CATEGORICAL; any other dataType, multiple dataTypes, or omitting dataType returns HTTP 400. For BOOLEAN, each value must be "true" or "false"; for NUMERIC, each value must be a finite number. Otherwise the request returns HTTP 400. - - value_min : typing.Optional[float] - Inclusive lower bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - value_max : typing.Optional[float] - Inclusive upper bound on the numeric value. Requires dataType=NUMERIC as a single value; otherwise the request returns HTTP 400. - - trace_id : typing.Optional[str] - Comma-separated list of trace IDs to filter by. Mutually exclusive with sessionId, experimentId. May be combined with observationId to scope the observation lookup to a specific trace. - - session_id : typing.Optional[str] - Comma-separated list of session IDs to filter by. Mutually exclusive with traceId, observationId, experimentId. - - observation_id : typing.Optional[str] - Comma-separated list of observation IDs to filter by. Requires traceId to be specified, because observation IDs are scoped to a trace. Mutually exclusive with sessionId, experimentId. Returns HTTP 400 when used without traceId. - - experiment_id : typing.Optional[str] - Comma-separated list of dataset run IDs (experiment IDs) to filter by. Mutually exclusive with traceId, sessionId, observationId. - - from_timestamp : typing.Optional[dt.datetime] - Inclusive lower bound on the score timestamp. - - to_timestamp : typing.Optional[dt.datetime] - Exclusive upper bound on the score timestamp. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[GetScoresV3Response] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/v3/scores", - method="GET", - params={ - "limit": limit, - "cursor": cursor, - "fields": fields, - "id": id, - "name": name, - "source": source, - "dataType": data_type, - "environment": environment, - "configId": config_id, - "queueId": queue_id, - "authorUserId": author_user_id, - "value": value, - "valueMin": value_min, - "valueMax": value_max, - "traceId": trace_id, - "sessionId": session_id, - "observationId": observation_id, - "experimentId": experiment_id, - "fromTimestamp": serialize_datetime(from_timestamp) - if from_timestamp is not None - else None, - "toTimestamp": serialize_datetime(to_timestamp) - if to_timestamp is not None - else None, - }, - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - GetScoresV3Response, - parse_obj_as( - type_=GetScoresV3Response, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/scores_v3/types/__init__.py b/langfuse/api/scores_v3/types/__init__.py deleted file mode 100644 index 14da0ca73..000000000 --- a/langfuse/api/scores_v3/types/__init__.py +++ /dev/null @@ -1,114 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .base_score_v3 import BaseScoreV3 - from .boolean_score_v3 import BooleanScoreV3 - from .categorical_score_v3 import CategoricalScoreV3 - from .correction_score_v3 import CorrectionScoreV3 - from .get_scores_v3meta import GetScoresV3Meta - from .get_scores_v3response import GetScoresV3Response - from .numeric_score_v3 import NumericScoreV3 - from .score_subject_experiment_v3 import ScoreSubjectExperimentV3 - from .score_subject_observation_v3 import ScoreSubjectObservationV3 - from .score_subject_session_v3 import ScoreSubjectSessionV3 - from .score_subject_trace_v3 import ScoreSubjectTraceV3 - from .score_subject_v3 import ( - ScoreSubjectV3, - ScoreSubjectV3_Experiment, - ScoreSubjectV3_Observation, - ScoreSubjectV3_Session, - ScoreSubjectV3_Trace, - ) - from .score_v3 import ( - ScoreV3, - ScoreV3_Boolean, - ScoreV3_Categorical, - ScoreV3_Correction, - ScoreV3_Numeric, - ScoreV3_Text, - ) - from .text_score_v3 import TextScoreV3 -_dynamic_imports: typing.Dict[str, str] = { - "BaseScoreV3": ".base_score_v3", - "BooleanScoreV3": ".boolean_score_v3", - "CategoricalScoreV3": ".categorical_score_v3", - "CorrectionScoreV3": ".correction_score_v3", - "GetScoresV3Meta": ".get_scores_v3meta", - "GetScoresV3Response": ".get_scores_v3response", - "NumericScoreV3": ".numeric_score_v3", - "ScoreSubjectExperimentV3": ".score_subject_experiment_v3", - "ScoreSubjectObservationV3": ".score_subject_observation_v3", - "ScoreSubjectSessionV3": ".score_subject_session_v3", - "ScoreSubjectTraceV3": ".score_subject_trace_v3", - "ScoreSubjectV3": ".score_subject_v3", - "ScoreSubjectV3_Experiment": ".score_subject_v3", - "ScoreSubjectV3_Observation": ".score_subject_v3", - "ScoreSubjectV3_Session": ".score_subject_v3", - "ScoreSubjectV3_Trace": ".score_subject_v3", - "ScoreV3": ".score_v3", - "ScoreV3_Boolean": ".score_v3", - "ScoreV3_Categorical": ".score_v3", - "ScoreV3_Correction": ".score_v3", - "ScoreV3_Numeric": ".score_v3", - "ScoreV3_Text": ".score_v3", - "TextScoreV3": ".text_score_v3", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = [ - "BaseScoreV3", - "BooleanScoreV3", - "CategoricalScoreV3", - "CorrectionScoreV3", - "GetScoresV3Meta", - "GetScoresV3Response", - "NumericScoreV3", - "ScoreSubjectExperimentV3", - "ScoreSubjectObservationV3", - "ScoreSubjectSessionV3", - "ScoreSubjectTraceV3", - "ScoreSubjectV3", - "ScoreSubjectV3_Experiment", - "ScoreSubjectV3_Observation", - "ScoreSubjectV3_Session", - "ScoreSubjectV3_Trace", - "ScoreV3", - "ScoreV3_Boolean", - "ScoreV3_Categorical", - "ScoreV3_Correction", - "ScoreV3_Numeric", - "ScoreV3_Text", - "TextScoreV3", -] From 63fcde76fd3567cb846659153a8a6381847a5ed0 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:01:39 +0400 Subject: [PATCH 28/31] refactor(experiments): drop sampling-aware gating of run-level scores (#1996) Co-authored-by: Cursor Agent --- langfuse/_client/client.py | 22 +--------- tests/unit/test_experiment.py | 78 ----------------------------------- 2 files changed, 1 insertion(+), 99 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index e4261bc30..b19bcecc8 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -20,7 +20,6 @@ List, Literal, Optional, - Set, Tuple, Type, Union, @@ -2676,7 +2675,6 @@ async def _run_experiment_async( # Set up concurrency control semaphore = asyncio.Semaphore(max_concurrency) - sampled_trace_ids: Set[str] = set() # Process all items async def process_item(item: ExperimentItem) -> ExperimentItemResult: @@ -2692,7 +2690,6 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: description, metadata, experiment_item_version, - sampled_trace_ids, ) # Run all items concurrently @@ -2724,17 +2721,8 @@ async def process_item(item: ExperimentItem) -> ExperimentItemResult: else None ) - # Without an exported item span the experiment does not exist on the server, - # so a run score would be orphaned. - stored_run_evaluations = run_evaluations if sampled_trace_ids else [] - if run_evaluations and not stored_run_evaluations: - langfuse_logger.debug( - "Skipping run-level scores for experiment %s: all items were sampled out.", - experiment_id, - ) - # Run-level scores attach to the experiment via dataset_run_id == experiment_id. - for evaluation in stored_run_evaluations: + for evaluation in run_evaluations: try: self.create_score( dataset_run_id=experiment_id, @@ -2806,7 +2794,6 @@ async def _process_experiment_item( experiment_description: Optional[str], experiment_metadata: Optional[Dict[str, Any]] = None, experiment_item_version: Optional[str] = None, - sampled_trace_ids: Optional[Set[str]] = None, ) -> ExperimentItemResult: with self.start_as_current_observation(name="experiment-item-run") as span: try: @@ -2915,13 +2902,6 @@ async def _process_experiment_item( otel_context_api.get_current() ), ) - # Only a sampled span carrying the experiment attributes - # creates the experiment on the server. - if ( - sampled_trace_ids is not None - and span._otel_span.get_span_context().trace_flags.sampled - ): - sampled_trace_ids.add(span.trace_id) # Write the observation metadata after the experiment # attributes, so the span attribute limit trims the # metadata instead of leaving no room for the output. diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index e8e7037d9..fea3587b2 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -745,84 +745,6 @@ def test_legacy_constructor_arguments_still_populate_new_fields(self): class TestExperimentRunScoreGating: - def test_run_scores_skipped_when_all_items_are_sampled_out( - self, langfuse_memory_client, monkeypatch - ): - from opentelemetry.sdk.trace.sampling import ALWAYS_OFF - - create_score = MagicMock() - monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) - monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") - monkeypatch.setattr( - langfuse_memory_client._resources.tracer_provider, "sampler", ALWAYS_OFF - ) - monkeypatch.setattr(langfuse_memory_client._otel_tracer, "sampler", ALWAYS_OFF) - - result = langfuse_memory_client.run_experiment( - name="exp", - data=[{"input": "a"}, {"input": "b"}], - task=lambda *, item, **kwargs: item["input"], - run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], - ) - - assert [e.name for e in result.run_evaluations] == ["run"] - assert _run_scores(create_score) == [] - - def test_run_scores_follow_the_item_span_sampling_decision( - self, langfuse_memory_client, monkeypatch - ): - from opentelemetry.sdk.trace.sampling import ( - Decision, - Sampler, - SamplingResult, - ) - - class ItemSpanSampler(Sampler): - def should_sample(self, parent_context, trace_id, name, *args, **kwargs): - decision = ( - Decision.RECORD_AND_SAMPLE - if name.startswith("experiment-item") - else Decision.DROP - ) - return SamplingResult(decision) - - def get_description(self): - return "ItemSpanSampler" - - sampler = ItemSpanSampler() - create_score = MagicMock() - monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) - monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") - monkeypatch.setattr( - langfuse_memory_client._resources.tracer_provider, "sampler", sampler - ) - monkeypatch.setattr(langfuse_memory_client._otel_tracer, "sampler", sampler) - - langfuse_memory_client.run_experiment( - name="exp", - data=[{"input": "a"}], - task=lambda *, item, **kwargs: item["input"], - run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], - ) - - assert len(_run_scores(create_score)) == 1 - - def test_run_scores_skipped_when_all_items_fail_before_experiment_attributes( - self, langfuse_memory_client, monkeypatch - ): - create_score = MagicMock() - monkeypatch.setattr(langfuse_memory_client, "create_score", create_score) - monkeypatch.setattr(langfuse_memory_client, "_get_project_id", lambda: "p") - - langfuse_memory_client.run_experiment( - name="exp", - data=[{"input": None}], - task=lambda *, item, **kwargs: item["input"], - run_evaluators=[lambda **kwargs: Evaluation(name="run", value=1.0)], - ) - - assert _run_scores(create_score) == [] - def test_run_scores_kept_when_the_task_fails_after_experiment_attributes( self, langfuse_memory_client, monkeypatch ): From e68a2bfb976186e23cd0b096cd7f4b61821b4c7c Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:22:41 +0400 Subject: [PATCH 29/31] test: read scores via api.scores.get_many in e2e helpers and docstring (#1998) * test: read scores via api.scores.get_many in e2e helpers Co-authored-by: hassieb.pakzad * docs: point Langfuse.api docstring at api.scores.get_many Co-authored-by: hassieb.pakzad --------- Co-authored-by: Cursor Agent --- langfuse/_client/client.py | 2 +- tests/e2e/test_batch_evaluation.py | 2 +- tests/e2e/test_experiments.py | 2 +- tests/support/utils.py | 2 +- tests/unit/test_e2e_support.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index b19bcecc8..c474f0640 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -437,7 +437,7 @@ def api(self) -> LangfuseAPI: `api.observations.get_many(trace_id=...)` (`/api/public/v2/observations`, cursor-paginated; request field groups with `fields`). A trace's name, user, session, tags and input/output are on its root observation. Read - scores with `api.scores_v3` and experiments with `api.experiments`. + scores with `api.scores.get_many` and experiments with `api.experiments`. - For large-scale aggregation (usage/cost by model, user, etc.), prefer the v2 Metrics API (`api.metrics.metrics(...)`) over paginating row-level data. diff --git a/tests/e2e/test_batch_evaluation.py b/tests/e2e/test_batch_evaluation.py index edbea8ebc..1e2d83ce8 100644 --- a/tests/e2e/test_batch_evaluation.py +++ b/tests/e2e/test_batch_evaluation.py @@ -54,7 +54,7 @@ def _wait_for_observations(filter_json: str, expected_count: int) -> List[Any]: def _wait_for_scores(**kwargs: Any) -> List[Any]: api = get_api(retry=False) response = wait_for_result( - lambda: api.scores_v3.get_many_v3(fields="details,subject", **kwargs), + lambda: api.scores.get_many(fields="details,subject", **kwargs), is_result_ready=lambda r: len(r.data) > 0, ) return list(response.data) diff --git a/tests/e2e/test_experiments.py b/tests/e2e/test_experiments.py index 4e3c7c125..ed4c23182 100644 --- a/tests/e2e/test_experiments.py +++ b/tests/e2e/test_experiments.py @@ -110,7 +110,7 @@ def get_observations(trace_id: str, *, is_ready: Callable[[list], bool]) -> list def get_trace_scores(trace_id: str, *, expected_count: int) -> list: api = get_api() return poll( - lambda: api.scores_v3.get_many_v3(trace_id=trace_id, fields="subject").data, + lambda: api.scores.get_many(trace_id=trace_id, fields="subject").data, lambda scores: len(scores) >= expected_count, ) diff --git a/tests/support/utils.py b/tests/support/utils.py index a6b126526..fc827eacb 100644 --- a/tests/support/utils.py +++ b/tests/support/utils.py @@ -405,7 +405,7 @@ def get_scores( cursor = None while True: - response = api.scores_v3.get_many_v3( + response = api.scores.get_many( fields=fields, limit=100, cursor=cursor, **filters ) scores.extend(response.data) diff --git a/tests/unit/test_e2e_support.py b/tests/unit/test_e2e_support.py index 98d25127c..b27e5aa0d 100644 --- a/tests/unit/test_e2e_support.py +++ b/tests/unit/test_e2e_support.py @@ -176,7 +176,7 @@ def get_many_v3(**kwargs): _install_client( monkeypatch, observations=SimpleNamespace(get_many=get_many), - scores_v3=SimpleNamespace(get_many_v3=get_many_v3), + scores=SimpleNamespace(get_many=get_many_v3), ) snapshot = wait_for_trace_snapshot( @@ -250,7 +250,7 @@ def get_many_v3(**kwargs): return _page(["score-1"], cursor="next") return _page(["score-2"]) - _install_client(monkeypatch, scores_v3=SimpleNamespace(get_many_v3=get_many_v3)) + _install_client(monkeypatch, scores=SimpleNamespace(get_many=get_many_v3)) scores = wait_for_scores(min_count=2, trace_id="trace-123") From 6599a97429d27f99e92251f00c70621bdb5a9952 Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Tue, 13 Oct 2026 22:07:58 +0200 Subject: [PATCH 30/31] feat(api): update API spec from langfuse/langfuse f0ea0c3 (#1999) * feat(api): update API spec from langfuse/langfuse f0ea0c3 * test: drop the single-score case from the scores create response test Co-authored-by: hassieb.pakzad --------- Co-authored-by: langfuse-bot Co-authored-by: Cursor Agent Co-authored-by: hassieb.pakzad --- langfuse/api/__init__.py | 6 ---- langfuse/api/scores/__init__.py | 6 ---- langfuse/api/scores/client.py | 32 +++++++++++++------ langfuse/api/scores/raw_client.py | 4 +-- langfuse/api/scores/types/__init__.py | 6 ---- .../types/create_score_batch_request.py | 25 --------------- .../api/scores/types/create_score_response.py | 17 ---------- .../api/scores/types/create_scores_request.py | 21 ++++++++++-- .../scores/types/create_scores_response.py | 5 +-- tests/unit/test_scores_batch_response.py | 8 +---- 10 files changed, 45 insertions(+), 85 deletions(-) delete mode 100644 langfuse/api/scores/types/create_score_batch_request.py delete mode 100644 langfuse/api/scores/types/create_score_response.py diff --git a/langfuse/api/__init__.py b/langfuse/api/__init__.py index b058ced5a..0952ed7d5 100644 --- a/langfuse/api/__init__.py +++ b/langfuse/api/__init__.py @@ -398,11 +398,9 @@ CategoricalScoreV3, CorrectionScoreV3, CreateScoreBatchError, - CreateScoreBatchRequest, CreateScoreBatchResponse, CreateScoreBatchResults, CreateScoreRequest, - CreateScoreResponse, CreateScoreSource, CreateScoresRequest, CreateScoresResponse, @@ -502,12 +500,10 @@ "CreateObservationEvent": ".ingestion", "CreatePromptRequest": ".prompts", "CreateScoreBatchError": ".scores", - "CreateScoreBatchRequest": ".scores", "CreateScoreBatchResponse": ".scores", "CreateScoreBatchResults": ".scores", "CreateScoreConfigRequest": ".score_configs", "CreateScoreRequest": ".scores", - "CreateScoreResponse": ".scores", "CreateScoreSource": ".scores", "CreateScoreValue": ".commons", "CreateScoresRequest": ".scores", @@ -916,12 +912,10 @@ def __dir__(): "CreateObservationEvent", "CreatePromptRequest", "CreateScoreBatchError", - "CreateScoreBatchRequest", "CreateScoreBatchResponse", "CreateScoreBatchResults", "CreateScoreConfigRequest", "CreateScoreRequest", - "CreateScoreResponse", "CreateScoreSource", "CreateScoreValue", "CreateScoresRequest", diff --git a/langfuse/api/scores/__init__.py b/langfuse/api/scores/__init__.py index d0251574f..a92174549 100644 --- a/langfuse/api/scores/__init__.py +++ b/langfuse/api/scores/__init__.py @@ -12,11 +12,9 @@ CategoricalScoreV3, CorrectionScoreV3, CreateScoreBatchError, - CreateScoreBatchRequest, CreateScoreBatchResponse, CreateScoreBatchResults, CreateScoreRequest, - CreateScoreResponse, CreateScoreSource, CreateScoresRequest, CreateScoresResponse, @@ -46,11 +44,9 @@ "CategoricalScoreV3": ".types", "CorrectionScoreV3": ".types", "CreateScoreBatchError": ".types", - "CreateScoreBatchRequest": ".types", "CreateScoreBatchResponse": ".types", "CreateScoreBatchResults": ".types", "CreateScoreRequest": ".types", - "CreateScoreResponse": ".types", "CreateScoreSource": ".types", "CreateScoresRequest": ".types", "CreateScoresResponse": ".types", @@ -109,11 +105,9 @@ def __dir__(): "CategoricalScoreV3", "CorrectionScoreV3", "CreateScoreBatchError", - "CreateScoreBatchRequest", "CreateScoreBatchResponse", "CreateScoreBatchResults", "CreateScoreRequest", - "CreateScoreResponse", "CreateScoreSource", "CreateScoresRequest", "CreateScoresResponse", diff --git a/langfuse/api/scores/client.py b/langfuse/api/scores/client.py index feac3cbca..c5582cfc5 100644 --- a/langfuse/api/scores/client.py +++ b/langfuse/api/scores/client.py @@ -36,7 +36,7 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoresResponse: """ - Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. + Create scores asynchronously from a list of scores. Returns 202 if all scores are accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; the endpoint returns 202 or 207. Parameters ---------- @@ -63,10 +63,16 @@ def create( base_url="https://yourhost.com/path/to/api", ) client.scores.create( - request=CreateScoreRequest( - name="name", - value=1.1, - ), + request=[ + CreateScoreRequest( + name="name", + value=1.1, + ), + CreateScoreRequest( + name="name", + value=1.1, + ), + ], ) """ _response = self._raw_client.create( @@ -287,7 +293,7 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> CreateScoresResponse: """ - Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. + Create scores asynchronously from a list of scores. Returns 202 if all scores are accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; the endpoint returns 202 or 207. Parameters ---------- @@ -319,10 +325,16 @@ async def create( async def main() -> None: await client.scores.create( - request=CreateScoreRequest( - name="name", - value=1.1, - ), + request=[ + CreateScoreRequest( + name="name", + value=1.1, + ), + CreateScoreRequest( + name="name", + value=1.1, + ), + ], ) diff --git a/langfuse/api/scores/raw_client.py b/langfuse/api/scores/raw_client.py index d0cb9663c..fcc599f7f 100644 --- a/langfuse/api/scores/raw_client.py +++ b/langfuse/api/scores/raw_client.py @@ -36,7 +36,7 @@ def create( request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[CreateScoresResponse]: """ - Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. + Create scores asynchronously from a list of scores. Returns 202 if all scores are accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; the endpoint returns 202 or 207. Parameters ---------- @@ -465,7 +465,7 @@ async def create( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[CreateScoresResponse]: """ - Create scores asynchronously. Single score: 200 with ID. Batch: 202 if accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; batches return 202 or 207. + Create scores asynchronously from a list of scores. Returns 202 if all scores are accepted, or 207 with accepted/rejected counts and error messages. Do not automatically retry a 207 batch. The generated reference shows only 200; the endpoint returns 202 or 207. Parameters ---------- diff --git a/langfuse/api/scores/types/__init__.py b/langfuse/api/scores/types/__init__.py index ae28d182b..d2578c4b5 100644 --- a/langfuse/api/scores/types/__init__.py +++ b/langfuse/api/scores/types/__init__.py @@ -11,11 +11,9 @@ from .categorical_score_v3 import CategoricalScoreV3 from .correction_score_v3 import CorrectionScoreV3 from .create_score_batch_error import CreateScoreBatchError - from .create_score_batch_request import CreateScoreBatchRequest from .create_score_batch_response import CreateScoreBatchResponse from .create_score_batch_results import CreateScoreBatchResults from .create_score_request import CreateScoreRequest - from .create_score_response import CreateScoreResponse from .create_score_source import CreateScoreSource from .create_scores_request import CreateScoresRequest from .create_scores_response import CreateScoresResponse @@ -48,11 +46,9 @@ "CategoricalScoreV3": ".categorical_score_v3", "CorrectionScoreV3": ".correction_score_v3", "CreateScoreBatchError": ".create_score_batch_error", - "CreateScoreBatchRequest": ".create_score_batch_request", "CreateScoreBatchResponse": ".create_score_batch_response", "CreateScoreBatchResults": ".create_score_batch_results", "CreateScoreRequest": ".create_score_request", - "CreateScoreResponse": ".create_score_response", "CreateScoreSource": ".create_score_source", "CreateScoresRequest": ".create_scores_request", "CreateScoresResponse": ".create_scores_response", @@ -111,11 +107,9 @@ def __dir__(): "CategoricalScoreV3", "CorrectionScoreV3", "CreateScoreBatchError", - "CreateScoreBatchRequest", "CreateScoreBatchResponse", "CreateScoreBatchResults", "CreateScoreRequest", - "CreateScoreResponse", "CreateScoreSource", "CreateScoresRequest", "CreateScoresResponse", diff --git a/langfuse/api/scores/types/create_score_batch_request.py b/langfuse/api/scores/types/create_score_batch_request.py deleted file mode 100644 index 47ab94b9b..000000000 --- a/langfuse/api/scores/types/create_score_batch_request.py +++ /dev/null @@ -1,25 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from .create_score_request import CreateScoreRequest - -""" -from langfuse.commons import ScoreDataType -from langfuse.scores import CreateScoreRequest - -[ - CreateScoreRequest( - name="accuracy", - value=0.9, - trace_id="cdef-1234-5678-90ab", - ), - CreateScoreRequest( - name="helpfulness", - value=1.0, - data_type=ScoreDataType.BOOLEAN, - trace_id="cdef-1234-5678-90ab", - ), -] -""" -CreateScoreBatchRequest = typing.List[CreateScoreRequest] diff --git a/langfuse/api/scores/types/create_score_response.py b/langfuse/api/scores/types/create_score_response.py deleted file mode 100644 index 1c20c0f3a..000000000 --- a/langfuse/api/scores/types/create_score_response.py +++ /dev/null @@ -1,17 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class CreateScoreResponse(UniversalBaseModel): - id: str = pydantic.Field() - """ - The id of the created object in Langfuse - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/scores/types/create_scores_request.py b/langfuse/api/scores/types/create_scores_request.py index 2acfce283..3c11bdfc9 100644 --- a/langfuse/api/scores/types/create_scores_request.py +++ b/langfuse/api/scores/types/create_scores_request.py @@ -2,7 +2,24 @@ import typing -from .create_score_batch_request import CreateScoreBatchRequest from .create_score_request import CreateScoreRequest -CreateScoresRequest = typing.Union[CreateScoreRequest, CreateScoreBatchRequest] +""" +from langfuse.commons import ScoreDataType +from langfuse.scores import CreateScoreRequest + +[ + CreateScoreRequest( + name="accuracy", + value=0.9, + trace_id="cdef-1234-5678-90ab", + ), + CreateScoreRequest( + name="helpfulness", + value=1.0, + data_type=ScoreDataType.BOOLEAN, + trace_id="cdef-1234-5678-90ab", + ), +] +""" +CreateScoresRequest = typing.List[CreateScoreRequest] diff --git a/langfuse/api/scores/types/create_scores_response.py b/langfuse/api/scores/types/create_scores_response.py index c3f4f7f10..7570cf33a 100644 --- a/langfuse/api/scores/types/create_scores_response.py +++ b/langfuse/api/scores/types/create_scores_response.py @@ -4,8 +4,5 @@ from .create_score_batch_response import CreateScoreBatchResponse from .create_score_batch_results import CreateScoreBatchResults -from .create_score_response import CreateScoreResponse -CreateScoresResponse = typing.Union[ - CreateScoreBatchResults, CreateScoreResponse, CreateScoreBatchResponse -] +CreateScoresResponse = typing.Union[CreateScoreBatchResults, CreateScoreBatchResponse] diff --git a/tests/unit/test_scores_batch_response.py b/tests/unit/test_scores_batch_response.py index c52eab692..ffc084620 100644 --- a/tests/unit/test_scores_batch_response.py +++ b/tests/unit/test_scores_batch_response.py @@ -19,16 +19,13 @@ CreateScoreBatchResults, ) from langfuse.api.scores.types.create_score_request import CreateScoreRequest -from langfuse.api.scores.types.create_score_response import CreateScoreResponse -SINGLE_REQUEST = CreateScoreRequest(name="accuracy", value=0.9, trace_id="trace-1") BATCH_REQUEST = [ CreateScoreRequest(name="accuracy", value=0.9, trace_id="trace-1"), CreateScoreRequest(name="tone", value="friendly", trace_id="trace-1"), ] CASES = [ - pytest.param(SINGLE_REQUEST, 200, {"id": "score-1"}, id="single-200"), pytest.param(BATCH_REQUEST, 202, {"message": "Accepted"}, id="batch-202"), pytest.param( BATCH_REQUEST, @@ -52,10 +49,7 @@ def handle(request: httpx.Request) -> httpx.Response: def _assert_parsed(response: Any, status: int, body: dict) -> None: - if status == 200: - assert isinstance(response, CreateScoreResponse) - assert response.id == body["id"] - elif status == 202: + if status == 202: assert isinstance(response, CreateScoreBatchResponse) assert response.message == "Accepted" else: From d3a7d0eb0cb0d93d51309b4d9a676de4330c529f Mon Sep 17 00:00:00 2001 From: langfuse-bot Date: Wed, 14 Oct 2026 08:47:11 +0200 Subject: [PATCH 31/31] feat(api): update API spec from langfuse/langfuse 7cb2eed (#2000) Co-authored-by: langfuse-bot --- langfuse/api/__init__.py | 124 +------ langfuse/api/client.py | 19 -- langfuse/api/commons/__init__.py | 3 + langfuse/api/commons/types/__init__.py | 3 + .../types/open_ai_usage.py | 0 langfuse/api/ingestion/__init__.py | 160 --------- langfuse/api/ingestion/client.py | 246 -------------- langfuse/api/ingestion/raw_client.py | 318 ------------------ langfuse/api/ingestion/types/__init__.py | 160 --------- langfuse/api/ingestion/types/base_event.py | 27 -- .../api/ingestion/types/create_event_body.py | 14 - .../api/ingestion/types/create_event_event.py | 19 -- .../ingestion/types/create_generation_body.py | 40 --- .../types/create_generation_event.py | 19 -- .../types/create_observation_event.py | 19 -- .../api/ingestion/types/create_span_body.py | 19 -- .../api/ingestion/types/create_span_event.py | 19 -- .../api/ingestion/types/ingestion_error.py | 17 - .../api/ingestion/types/ingestion_event.py | 141 -------- .../api/ingestion/types/ingestion_response.py | 23 -- .../api/ingestion/types/ingestion_success.py | 15 - .../api/ingestion/types/ingestion_usage.py | 8 - .../api/ingestion/types/observation_body.py | 53 --- .../api/ingestion/types/observation_type.py | 54 --- .../types/open_ai_completion_usage_schema.py | 26 -- .../types/open_ai_response_usage_schema.py | 24 -- .../types/optional_observation_body.py | 36 -- langfuse/api/ingestion/types/score_event.py | 15 - langfuse/api/ingestion/types/trace_body.py | 36 -- langfuse/api/ingestion/types/trace_event.py | 19 -- .../api/ingestion/types/update_event_body.py | 14 - .../ingestion/types/update_generation_body.py | 40 --- .../types/update_generation_event.py | 19 -- .../types/update_observation_event.py | 19 -- .../api/ingestion/types/update_span_body.py | 19 -- .../api/ingestion/types/update_span_event.py | 19 -- langfuse/api/ingestion/types/usage_details.py | 10 - langfuse/api/scores/__init__.py | 3 + langfuse/api/scores/types/__init__.py | 3 + .../{ingestion => scores}/types/score_body.py | 2 +- 40 files changed, 17 insertions(+), 1807 deletions(-) rename langfuse/api/{ingestion => commons}/types/open_ai_usage.py (100%) delete mode 100644 langfuse/api/ingestion/__init__.py delete mode 100644 langfuse/api/ingestion/client.py delete mode 100644 langfuse/api/ingestion/raw_client.py delete mode 100644 langfuse/api/ingestion/types/__init__.py delete mode 100644 langfuse/api/ingestion/types/base_event.py delete mode 100644 langfuse/api/ingestion/types/create_event_body.py delete mode 100644 langfuse/api/ingestion/types/create_event_event.py delete mode 100644 langfuse/api/ingestion/types/create_generation_body.py delete mode 100644 langfuse/api/ingestion/types/create_generation_event.py delete mode 100644 langfuse/api/ingestion/types/create_observation_event.py delete mode 100644 langfuse/api/ingestion/types/create_span_body.py delete mode 100644 langfuse/api/ingestion/types/create_span_event.py delete mode 100644 langfuse/api/ingestion/types/ingestion_error.py delete mode 100644 langfuse/api/ingestion/types/ingestion_event.py delete mode 100644 langfuse/api/ingestion/types/ingestion_response.py delete mode 100644 langfuse/api/ingestion/types/ingestion_success.py delete mode 100644 langfuse/api/ingestion/types/ingestion_usage.py delete mode 100644 langfuse/api/ingestion/types/observation_body.py delete mode 100644 langfuse/api/ingestion/types/observation_type.py delete mode 100644 langfuse/api/ingestion/types/open_ai_completion_usage_schema.py delete mode 100644 langfuse/api/ingestion/types/open_ai_response_usage_schema.py delete mode 100644 langfuse/api/ingestion/types/optional_observation_body.py delete mode 100644 langfuse/api/ingestion/types/score_event.py delete mode 100644 langfuse/api/ingestion/types/trace_body.py delete mode 100644 langfuse/api/ingestion/types/trace_event.py delete mode 100644 langfuse/api/ingestion/types/update_event_body.py delete mode 100644 langfuse/api/ingestion/types/update_generation_body.py delete mode 100644 langfuse/api/ingestion/types/update_generation_event.py delete mode 100644 langfuse/api/ingestion/types/update_observation_event.py delete mode 100644 langfuse/api/ingestion/types/update_span_body.py delete mode 100644 langfuse/api/ingestion/types/update_span_event.py delete mode 100644 langfuse/api/ingestion/types/usage_details.py rename langfuse/api/{ingestion => scores}/types/score_body.py (98%) diff --git a/langfuse/api/__init__.py b/langfuse/api/__init__.py index 0952ed7d5..e4763032e 100644 --- a/langfuse/api/__init__.py +++ b/langfuse/api/__init__.py @@ -20,7 +20,6 @@ experiments, feedback, health, - ingestion, llm_connections, media, metrics, @@ -101,6 +100,7 @@ NumericScore, ObservationLevel, ObservationV2, + OpenAiUsage, PricingTier, PricingTierAttributeCondition, PricingTierAttributeSource, @@ -257,47 +257,6 @@ SubmitFeedbackResponse, ) from .health import HealthResponse, ServiceUnavailableError - from .ingestion import ( - BaseEvent, - CreateEventBody, - CreateEventEvent, - CreateGenerationBody, - CreateGenerationEvent, - CreateObservationEvent, - CreateSpanBody, - CreateSpanEvent, - IngestionError, - IngestionEvent, - IngestionEvent_EventCreate, - IngestionEvent_GenerationCreate, - IngestionEvent_GenerationUpdate, - IngestionEvent_ObservationCreate, - IngestionEvent_ObservationUpdate, - IngestionEvent_ScoreCreate, - IngestionEvent_SpanCreate, - IngestionEvent_SpanUpdate, - IngestionEvent_TraceCreate, - IngestionResponse, - IngestionSuccess, - IngestionUsage, - ObservationBody, - ObservationType, - OpenAiCompletionUsageSchema, - OpenAiResponseUsageSchema, - OpenAiUsage, - OptionalObservationBody, - ScoreBody, - ScoreEvent, - TraceBody, - TraceEvent, - UpdateEventBody, - UpdateGenerationBody, - UpdateGenerationEvent, - UpdateObservationEvent, - UpdateSpanBody, - UpdateSpanEvent, - UsageDetails, - ) from .llm_connections import ( DeleteLlmConnectionResponse, LlmAdapter, @@ -407,6 +366,7 @@ GetScoresV3Meta, GetScoresV3Response, NumericScoreV3, + ScoreBody, ScoreSubjectExperimentV3, ScoreSubjectObservationV3, ScoreSubjectSessionV3, @@ -440,7 +400,6 @@ "AsyncLangfuseAPI": ".client", "AuthenticationScheme": ".scim", "BadRequestError": ".evaluation_errors", - "BaseEvent": ".ingestion", "BasePrompt": ".prompts", "BaseScore": ".commons", "BaseScoreV3": ".scores", @@ -491,13 +450,8 @@ "CreateEvaluatorRequest": ".evaluators", "CreateEvaluatorRequest_Code": ".evaluators", "CreateEvaluatorRequest_LlmAsJudge": ".evaluators", - "CreateEventBody": ".ingestion", - "CreateEventEvent": ".ingestion", - "CreateGenerationBody": ".ingestion", - "CreateGenerationEvent": ".ingestion", "CreateLlmAsJudgeEvaluatorRequest": ".evaluators", "CreateModelRequest": ".models", - "CreateObservationEvent": ".ingestion", "CreatePromptRequest": ".prompts", "CreateScoreBatchError": ".scores", "CreateScoreBatchResponse": ".scores", @@ -508,8 +462,6 @@ "CreateScoreValue": ".commons", "CreateScoresRequest": ".scores", "CreateScoresResponse": ".scores", - "CreateSpanBody": ".ingestion", - "CreateSpanEvent": ".ingestion", "CreateTextPromptRequest": ".prompts", "CreateTextPromptType": ".prompts", "Creator": ".evaluators", @@ -600,20 +552,6 @@ "GetScoresV3Meta": ".scores", "GetScoresV3Response": ".scores", "HealthResponse": ".health", - "IngestionError": ".ingestion", - "IngestionEvent": ".ingestion", - "IngestionEvent_EventCreate": ".ingestion", - "IngestionEvent_GenerationCreate": ".ingestion", - "IngestionEvent_GenerationUpdate": ".ingestion", - "IngestionEvent_ObservationCreate": ".ingestion", - "IngestionEvent_ObservationUpdate": ".ingestion", - "IngestionEvent_ScoreCreate": ".ingestion", - "IngestionEvent_SpanCreate": ".ingestion", - "IngestionEvent_SpanUpdate": ".ingestion", - "IngestionEvent_TraceCreate": ".ingestion", - "IngestionResponse": ".ingestion", - "IngestionSuccess": ".ingestion", - "IngestionUsage": ".ingestion", "InternalServerError": ".evaluation_errors", "LangfuseAPI": ".client", "LegacyEvaluationObject": ".evaluation_commons", @@ -641,16 +579,11 @@ "NumberObjectEvaluationRuleFilter": ".evaluation_commons", "NumericScore": ".commons", "NumericScoreV3": ".scores", - "ObservationBody": ".ingestion", "ObservationLevel": ".commons", - "ObservationType": ".ingestion", "ObservationV2": ".commons", "ObservationsV2Meta": ".observations", "ObservationsV2Response": ".observations", - "OpenAiCompletionUsageSchema": ".ingestion", - "OpenAiResponseUsageSchema": ".ingestion", - "OpenAiUsage": ".ingestion", - "OptionalObservationBody": ".ingestion", + "OpenAiUsage": ".commons", "Organization": ".projects", "OrganizationApiKey": ".organizations", "OrganizationApiKeysResponse": ".organizations", @@ -719,12 +652,11 @@ "ScimUser": ".scim", "ScimUsersListResponse": ".scim", "Score": ".commons", - "ScoreBody": ".ingestion", + "ScoreBody": ".scores", "ScoreConfig": ".commons", "ScoreConfigDataType": ".commons", "ScoreConfigs": ".score_configs", "ScoreDataType": ".commons", - "ScoreEvent": ".ingestion", "ScoreSource": ".commons", "ScoreSubjectExperimentV3": ".scores", "ScoreSubjectObservationV3": ".scores", @@ -757,25 +689,16 @@ "TextScore": ".commons", "TextScoreV3": ".scores", "TooManyRequestsError": ".evaluation_errors", - "TraceBody": ".ingestion", - "TraceEvent": ".ingestion", "UnauthorizedError": ".commons", "UpdateAnnotationQueueItemRequest": ".annotation_queues", "UpdateCodeEvaluatorRequest": ".evaluators", "UpdateEvaluationRuleRequest": ".evaluation_rules", "UpdateEvaluatorMetadataRequest": ".evaluators", "UpdateEvaluatorRequest": ".evaluators", - "UpdateEventBody": ".ingestion", - "UpdateGenerationBody": ".ingestion", - "UpdateGenerationEvent": ".ingestion", "UpdateLlmAsJudgeEvaluatorRequest": ".evaluators", - "UpdateObservationEvent": ".ingestion", "UpdateScoreConfigRequest": ".score_configs", - "UpdateSpanBody": ".ingestion", - "UpdateSpanEvent": ".ingestion", "UpsertLlmConnectionRequest": ".llm_connections", "Usage": ".commons", - "UsageDetails": ".ingestion", "UserMeta": ".scim", "annotation_queues": ".annotation_queues", "blob_storage_integrations": ".blob_storage_integrations", @@ -790,7 +713,6 @@ "experiments": ".experiments", "feedback": ".feedback", "health": ".health", - "ingestion": ".ingestion", "llm_connections": ".llm_connections", "media": ".media", "metrics": ".metrics", @@ -852,7 +774,6 @@ def __dir__(): "AsyncLangfuseAPI", "AuthenticationScheme", "BadRequestError", - "BaseEvent", "BasePrompt", "BaseScore", "BaseScoreV3", @@ -903,13 +824,8 @@ def __dir__(): "CreateEvaluatorRequest", "CreateEvaluatorRequest_Code", "CreateEvaluatorRequest_LlmAsJudge", - "CreateEventBody", - "CreateEventEvent", - "CreateGenerationBody", - "CreateGenerationEvent", "CreateLlmAsJudgeEvaluatorRequest", "CreateModelRequest", - "CreateObservationEvent", "CreatePromptRequest", "CreateScoreBatchError", "CreateScoreBatchResponse", @@ -920,8 +836,6 @@ def __dir__(): "CreateScoreValue", "CreateScoresRequest", "CreateScoresResponse", - "CreateSpanBody", - "CreateSpanEvent", "CreateTextPromptRequest", "CreateTextPromptType", "Creator", @@ -1012,20 +926,6 @@ def __dir__(): "GetScoresV3Meta", "GetScoresV3Response", "HealthResponse", - "IngestionError", - "IngestionEvent", - "IngestionEvent_EventCreate", - "IngestionEvent_GenerationCreate", - "IngestionEvent_GenerationUpdate", - "IngestionEvent_ObservationCreate", - "IngestionEvent_ObservationUpdate", - "IngestionEvent_ScoreCreate", - "IngestionEvent_SpanCreate", - "IngestionEvent_SpanUpdate", - "IngestionEvent_TraceCreate", - "IngestionResponse", - "IngestionSuccess", - "IngestionUsage", "InternalServerError", "LangfuseAPI", "LegacyEvaluationObject", @@ -1053,16 +953,11 @@ def __dir__(): "NumberObjectEvaluationRuleFilter", "NumericScore", "NumericScoreV3", - "ObservationBody", "ObservationLevel", - "ObservationType", "ObservationV2", "ObservationsV2Meta", "ObservationsV2Response", - "OpenAiCompletionUsageSchema", - "OpenAiResponseUsageSchema", "OpenAiUsage", - "OptionalObservationBody", "Organization", "OrganizationApiKey", "OrganizationApiKeysResponse", @@ -1136,7 +1031,6 @@ def __dir__(): "ScoreConfigDataType", "ScoreConfigs", "ScoreDataType", - "ScoreEvent", "ScoreSource", "ScoreSubjectExperimentV3", "ScoreSubjectObservationV3", @@ -1169,25 +1063,16 @@ def __dir__(): "TextScore", "TextScoreV3", "TooManyRequestsError", - "TraceBody", - "TraceEvent", "UnauthorizedError", "UpdateAnnotationQueueItemRequest", "UpdateCodeEvaluatorRequest", "UpdateEvaluationRuleRequest", "UpdateEvaluatorMetadataRequest", "UpdateEvaluatorRequest", - "UpdateEventBody", - "UpdateGenerationBody", - "UpdateGenerationEvent", "UpdateLlmAsJudgeEvaluatorRequest", - "UpdateObservationEvent", "UpdateScoreConfigRequest", - "UpdateSpanBody", - "UpdateSpanEvent", "UpsertLlmConnectionRequest", "Usage", - "UsageDetails", "UserMeta", "annotation_queues", "blob_storage_integrations", @@ -1202,7 +1087,6 @@ def __dir__(): "experiments", "feedback", "health", - "ingestion", "llm_connections", "media", "metrics", diff --git a/langfuse/api/client.py b/langfuse/api/client.py index 30708f728..a6d960093 100644 --- a/langfuse/api/client.py +++ b/langfuse/api/client.py @@ -27,7 +27,6 @@ from .experiments.client import AsyncExperimentsClient, ExperimentsClient from .feedback.client import AsyncFeedbackClient, FeedbackClient from .health.client import AsyncHealthClient, HealthClient - from .ingestion.client import AsyncIngestionClient, IngestionClient from .llm_connections.client import AsyncLlmConnectionsClient, LlmConnectionsClient from .media.client import AsyncMediaClient, MediaClient from .metrics.client import AsyncMetricsClient, MetricsClient @@ -135,7 +134,6 @@ def __init__( self._experiments: typing.Optional[ExperimentsClient] = None self._feedback: typing.Optional[FeedbackClient] = None self._health: typing.Optional[HealthClient] = None - self._ingestion: typing.Optional[IngestionClient] = None self._llm_connections: typing.Optional[LlmConnectionsClient] = None self._media: typing.Optional[MediaClient] = None self._metrics: typing.Optional[MetricsClient] = None @@ -240,14 +238,6 @@ def health(self): self._health = HealthClient(client_wrapper=self._client_wrapper) return self._health - @property - def ingestion(self): - if self._ingestion is None: - from .ingestion.client import IngestionClient # noqa: E402 - - self._ingestion = IngestionClient(client_wrapper=self._client_wrapper) - return self._ingestion - @property def llm_connections(self): if self._llm_connections is None: @@ -469,7 +459,6 @@ def __init__( self._experiments: typing.Optional[AsyncExperimentsClient] = None self._feedback: typing.Optional[AsyncFeedbackClient] = None self._health: typing.Optional[AsyncHealthClient] = None - self._ingestion: typing.Optional[AsyncIngestionClient] = None self._llm_connections: typing.Optional[AsyncLlmConnectionsClient] = None self._media: typing.Optional[AsyncMediaClient] = None self._metrics: typing.Optional[AsyncMetricsClient] = None @@ -580,14 +569,6 @@ def health(self): self._health = AsyncHealthClient(client_wrapper=self._client_wrapper) return self._health - @property - def ingestion(self): - if self._ingestion is None: - from .ingestion.client import AsyncIngestionClient # noqa: E402 - - self._ingestion = AsyncIngestionClient(client_wrapper=self._client_wrapper) - return self._ingestion - @property def llm_connections(self): if self._llm_connections is None: diff --git a/langfuse/api/commons/__init__.py b/langfuse/api/commons/__init__.py index bf6de4b6e..dc0e9f1a4 100644 --- a/langfuse/api/commons/__init__.py +++ b/langfuse/api/commons/__init__.py @@ -29,6 +29,7 @@ NumericScore, ObservationLevel, ObservationV2, + OpenAiUsage, PricingTier, PricingTierAttributeCondition, PricingTierAttributeSource, @@ -85,6 +86,7 @@ "NumericScore": ".types", "ObservationLevel": ".types", "ObservationV2": ".types", + "OpenAiUsage": ".types", "PricingTier": ".types", "PricingTierAttributeCondition": ".types", "PricingTierAttributeSource": ".types", @@ -164,6 +166,7 @@ def __dir__(): "NumericScore", "ObservationLevel", "ObservationV2", + "OpenAiUsage", "PricingTier", "PricingTierAttributeCondition", "PricingTierAttributeSource", diff --git a/langfuse/api/commons/types/__init__.py b/langfuse/api/commons/types/__init__.py index 412219c03..2fbabb37e 100644 --- a/langfuse/api/commons/types/__init__.py +++ b/langfuse/api/commons/types/__init__.py @@ -28,6 +28,7 @@ from .numeric_score import NumericScore from .observation_level import ObservationLevel from .observation_v2 import ObservationV2 + from .open_ai_usage import OpenAiUsage from .pricing_tier import PricingTier from .pricing_tier_attribute_condition import PricingTierAttributeCondition from .pricing_tier_attribute_source import PricingTierAttributeSource @@ -74,6 +75,7 @@ "NumericScore": ".numeric_score", "ObservationLevel": ".observation_level", "ObservationV2": ".observation_v2", + "OpenAiUsage": ".open_ai_usage", "PricingTier": ".pricing_tier", "PricingTierAttributeCondition": ".pricing_tier_attribute_condition", "PricingTierAttributeSource": ".pricing_tier_attribute_source", @@ -148,6 +150,7 @@ def __dir__(): "NumericScore", "ObservationLevel", "ObservationV2", + "OpenAiUsage", "PricingTier", "PricingTierAttributeCondition", "PricingTierAttributeSource", diff --git a/langfuse/api/ingestion/types/open_ai_usage.py b/langfuse/api/commons/types/open_ai_usage.py similarity index 100% rename from langfuse/api/ingestion/types/open_ai_usage.py rename to langfuse/api/commons/types/open_ai_usage.py diff --git a/langfuse/api/ingestion/__init__.py b/langfuse/api/ingestion/__init__.py deleted file mode 100644 index 7a0d7c762..000000000 --- a/langfuse/api/ingestion/__init__.py +++ /dev/null @@ -1,160 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .types import ( - BaseEvent, - CreateEventBody, - CreateEventEvent, - CreateGenerationBody, - CreateGenerationEvent, - CreateObservationEvent, - CreateSpanBody, - CreateSpanEvent, - IngestionError, - IngestionEvent, - IngestionEvent_EventCreate, - IngestionEvent_GenerationCreate, - IngestionEvent_GenerationUpdate, - IngestionEvent_ObservationCreate, - IngestionEvent_ObservationUpdate, - IngestionEvent_ScoreCreate, - IngestionEvent_SpanCreate, - IngestionEvent_SpanUpdate, - IngestionEvent_TraceCreate, - IngestionResponse, - IngestionSuccess, - IngestionUsage, - ObservationBody, - ObservationType, - OpenAiCompletionUsageSchema, - OpenAiResponseUsageSchema, - OpenAiUsage, - OptionalObservationBody, - ScoreBody, - ScoreEvent, - TraceBody, - TraceEvent, - UpdateEventBody, - UpdateGenerationBody, - UpdateGenerationEvent, - UpdateObservationEvent, - UpdateSpanBody, - UpdateSpanEvent, - UsageDetails, - ) -_dynamic_imports: typing.Dict[str, str] = { - "BaseEvent": ".types", - "CreateEventBody": ".types", - "CreateEventEvent": ".types", - "CreateGenerationBody": ".types", - "CreateGenerationEvent": ".types", - "CreateObservationEvent": ".types", - "CreateSpanBody": ".types", - "CreateSpanEvent": ".types", - "IngestionError": ".types", - "IngestionEvent": ".types", - "IngestionEvent_EventCreate": ".types", - "IngestionEvent_GenerationCreate": ".types", - "IngestionEvent_GenerationUpdate": ".types", - "IngestionEvent_ObservationCreate": ".types", - "IngestionEvent_ObservationUpdate": ".types", - "IngestionEvent_ScoreCreate": ".types", - "IngestionEvent_SpanCreate": ".types", - "IngestionEvent_SpanUpdate": ".types", - "IngestionEvent_TraceCreate": ".types", - "IngestionResponse": ".types", - "IngestionSuccess": ".types", - "IngestionUsage": ".types", - "ObservationBody": ".types", - "ObservationType": ".types", - "OpenAiCompletionUsageSchema": ".types", - "OpenAiResponseUsageSchema": ".types", - "OpenAiUsage": ".types", - "OptionalObservationBody": ".types", - "ScoreBody": ".types", - "ScoreEvent": ".types", - "TraceBody": ".types", - "TraceEvent": ".types", - "UpdateEventBody": ".types", - "UpdateGenerationBody": ".types", - "UpdateGenerationEvent": ".types", - "UpdateObservationEvent": ".types", - "UpdateSpanBody": ".types", - "UpdateSpanEvent": ".types", - "UsageDetails": ".types", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = [ - "BaseEvent", - "CreateEventBody", - "CreateEventEvent", - "CreateGenerationBody", - "CreateGenerationEvent", - "CreateObservationEvent", - "CreateSpanBody", - "CreateSpanEvent", - "IngestionError", - "IngestionEvent", - "IngestionEvent_EventCreate", - "IngestionEvent_GenerationCreate", - "IngestionEvent_GenerationUpdate", - "IngestionEvent_ObservationCreate", - "IngestionEvent_ObservationUpdate", - "IngestionEvent_ScoreCreate", - "IngestionEvent_SpanCreate", - "IngestionEvent_SpanUpdate", - "IngestionEvent_TraceCreate", - "IngestionResponse", - "IngestionSuccess", - "IngestionUsage", - "ObservationBody", - "ObservationType", - "OpenAiCompletionUsageSchema", - "OpenAiResponseUsageSchema", - "OpenAiUsage", - "OptionalObservationBody", - "ScoreBody", - "ScoreEvent", - "TraceBody", - "TraceEvent", - "UpdateEventBody", - "UpdateGenerationBody", - "UpdateGenerationEvent", - "UpdateObservationEvent", - "UpdateSpanBody", - "UpdateSpanEvent", - "UsageDetails", -] diff --git a/langfuse/api/ingestion/client.py b/langfuse/api/ingestion/client.py deleted file mode 100644 index 4e78c2e2a..000000000 --- a/langfuse/api/ingestion/client.py +++ /dev/null @@ -1,246 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import typing_extensions -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.request_options import RequestOptions -from .raw_client import AsyncRawIngestionClient, RawIngestionClient -from .types.ingestion_event import IngestionEvent -from .types.ingestion_response import IngestionResponse - -# this is used as the default value for optional parameters -OMIT = typing.cast(typing.Any, ...) - - -class IngestionClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawIngestionClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> RawIngestionClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - RawIngestionClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", - category=None, - ) - def batch( - self, - *, - batch: typing.Sequence[IngestionEvent], - metadata: typing.Optional[typing.Any] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> IngestionResponse: - """ - **Legacy endpoint for batch ingestion for Langfuse Observability.** - - This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. - - To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api - - To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry - - To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api - - The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - - Within each batch, there can be multiple events. - Each event has a type, an id, a timestamp, metadata and a body. - Internally, we refer to this as the "event envelope" as it tells us something about the event but not the trace. - We use the event id within this envelope to deduplicate messages to avoid processing the same event twice, i.e. the event id should be unique per request. - The event.body.id is the ID of the actual trace and will be used for updates and will be visible within the Langfuse App. - I.e. if you want to update a trace, you'd use the same body id, but separate event IDs. - - Notes: - - Introduction to data model: https://langfuse.com/docs/observability/data-model - - Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly. - - The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors. - - Parameters - ---------- - batch : typing.Sequence[IngestionEvent] - Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. - - metadata : typing.Optional[typing.Any] - Optional. Metadata field used by the Langfuse SDKs for debugging. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - IngestionResponse - - Examples - -------- - import datetime - - from langfuse import LangfuseAPI - from langfuse.ingestion import IngestionEvent_TraceCreate, TraceBody - - client = LangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - client.ingestion.batch( - batch=[ - IngestionEvent_TraceCreate( - id="abcdef-1234-5678-90ab", - timestamp="2022-01-01T00:00:00.000Z", - body=TraceBody( - id="abcdef-1234-5678-90ab", - timestamp=datetime.datetime.fromisoformat( - "2022-01-01 00:00:00+00:00", - ), - environment="production", - name="My Trace", - user_id="1234-5678-90ab-cdef", - input="My input", - output="My output", - session_id="1234-5678-90ab-cdef", - release="1.0.0", - version="1.0.0", - metadata="My metadata", - tags=["tag1", "tag2"], - public=True, - ), - ) - ], - ) - """ - _response = self._raw_client.batch( - batch=batch, metadata=metadata, request_options=request_options - ) - return _response.data - - -class AsyncIngestionClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawIngestionClient(client_wrapper=client_wrapper) - - @property - def with_raw_response(self) -> AsyncRawIngestionClient: - """ - Retrieves a raw implementation of this client that returns raw responses. - - Returns - ------- - AsyncRawIngestionClient - """ - return self._raw_client - - @typing_extensions.deprecated( - "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", - category=None, - ) - async def batch( - self, - *, - batch: typing.Sequence[IngestionEvent], - metadata: typing.Optional[typing.Any] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> IngestionResponse: - """ - **Legacy endpoint for batch ingestion for Langfuse Observability.** - - This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. - - To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api - - To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry - - To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api - - The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - - Within each batch, there can be multiple events. - Each event has a type, an id, a timestamp, metadata and a body. - Internally, we refer to this as the "event envelope" as it tells us something about the event but not the trace. - We use the event id within this envelope to deduplicate messages to avoid processing the same event twice, i.e. the event id should be unique per request. - The event.body.id is the ID of the actual trace and will be used for updates and will be visible within the Langfuse App. - I.e. if you want to update a trace, you'd use the same body id, but separate event IDs. - - Notes: - - Introduction to data model: https://langfuse.com/docs/observability/data-model - - Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly. - - The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors. - - Parameters - ---------- - batch : typing.Sequence[IngestionEvent] - Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. - - metadata : typing.Optional[typing.Any] - Optional. Metadata field used by the Langfuse SDKs for debugging. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - IngestionResponse - - Examples - -------- - import asyncio - import datetime - - from langfuse import AsyncLangfuseAPI - from langfuse.ingestion import IngestionEvent_TraceCreate, TraceBody - - client = AsyncLangfuseAPI( - x_langfuse_sdk_name="YOUR_X_LANGFUSE_SDK_NAME", - x_langfuse_sdk_version="YOUR_X_LANGFUSE_SDK_VERSION", - x_langfuse_public_key="YOUR_X_LANGFUSE_PUBLIC_KEY", - username="YOUR_USERNAME", - password="YOUR_PASSWORD", - base_url="https://yourhost.com/path/to/api", - ) - - - async def main() -> None: - await client.ingestion.batch( - batch=[ - IngestionEvent_TraceCreate( - id="abcdef-1234-5678-90ab", - timestamp="2022-01-01T00:00:00.000Z", - body=TraceBody( - id="abcdef-1234-5678-90ab", - timestamp=datetime.datetime.fromisoformat( - "2022-01-01 00:00:00+00:00", - ), - environment="production", - name="My Trace", - user_id="1234-5678-90ab-cdef", - input="My input", - output="My output", - session_id="1234-5678-90ab-cdef", - release="1.0.0", - version="1.0.0", - metadata="My metadata", - tags=["tag1", "tag2"], - public=True, - ), - ) - ], - ) - - - asyncio.run(main()) - """ - _response = await self._raw_client.batch( - batch=batch, metadata=metadata, request_options=request_options - ) - return _response.data diff --git a/langfuse/api/ingestion/raw_client.py b/langfuse/api/ingestion/raw_client.py deleted file mode 100644 index 95b01adb3..000000000 --- a/langfuse/api/ingestion/raw_client.py +++ /dev/null @@ -1,318 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing -import typing_extensions -from json.decoder import JSONDecodeError - -from ..commons.errors.access_denied_error import AccessDeniedError -from ..commons.errors.error import Error -from ..commons.errors.method_not_allowed_error import MethodNotAllowedError -from ..commons.errors.not_found_error import NotFoundError -from ..commons.errors.unauthorized_error import UnauthorizedError -from ..core.api_error import ApiError -from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.http_response import AsyncHttpResponse, HttpResponse -from ..core.pydantic_utilities import parse_obj_as -from ..core.request_options import RequestOptions -from ..core.serialization import convert_and_respect_annotation_metadata -from .types.ingestion_event import IngestionEvent -from .types.ingestion_response import IngestionResponse - -# this is used as the default value for optional parameters -OMIT = typing.cast(typing.Any, ...) - - -class RawIngestionClient: - def __init__(self, *, client_wrapper: SyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", - category=None, - ) - def batch( - self, - *, - batch: typing.Sequence[IngestionEvent], - metadata: typing.Optional[typing.Any] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> HttpResponse[IngestionResponse]: - """ - **Legacy endpoint for batch ingestion for Langfuse Observability.** - - This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. - - To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api - - To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry - - To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api - - The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - - Within each batch, there can be multiple events. - Each event has a type, an id, a timestamp, metadata and a body. - Internally, we refer to this as the "event envelope" as it tells us something about the event but not the trace. - We use the event id within this envelope to deduplicate messages to avoid processing the same event twice, i.e. the event id should be unique per request. - The event.body.id is the ID of the actual trace and will be used for updates and will be visible within the Langfuse App. - I.e. if you want to update a trace, you'd use the same body id, but separate event IDs. - - Notes: - - Introduction to data model: https://langfuse.com/docs/observability/data-model - - Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly. - - The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors. - - Parameters - ---------- - batch : typing.Sequence[IngestionEvent] - Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. - - metadata : typing.Optional[typing.Any] - Optional. Metadata field used by the Langfuse SDKs for debugging. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - HttpResponse[IngestionResponse] - """ - _response = self._client_wrapper.httpx_client.request( - "api/public/ingestion", - method="POST", - json={ - "batch": convert_and_respect_annotation_metadata( - object_=batch, - annotation=typing.Sequence[IngestionEvent], - direction="write", - ), - "metadata": metadata, - }, - request_options=request_options, - omit=OMIT, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - IngestionResponse, - parse_obj_as( - type_=IngestionResponse, # type: ignore - object_=_response.json(), - ), - ) - return HttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) - - -class AsyncRawIngestionClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - @typing_extensions.deprecated( - "On Langfuse Cloud, this is the deprecated Langfuse v3 ingestion API and it is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs; if you use custom auto-instrumentation, send them to the OpenTelemetry endpoint at `POST /api/public/otel/v1/traces` (for example with curl); see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`; all other public APIs may have data delays of several minutes. Self-hosted deployments are unaffected by the November 16 date; this behavior begins only when they enable v4-only write mode.", - category=None, - ) - async def batch( - self, - *, - batch: typing.Sequence[IngestionEvent], - metadata: typing.Optional[typing.Any] = OMIT, - request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncHttpResponse[IngestionResponse]: - """ - **Legacy endpoint for batch ingestion for Langfuse Observability.** - - This is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types. - - To write scores, prefer `POST /api/public/scores`: https://langfuse.com/docs/api-and-data-platform/features/scores-api - - To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl: https://langfuse.com/integrations/native/opentelemetry - - To read data back, use the v4 read APIs: https://langfuse.com/docs/api-and-data-platform/features/observations-api and https://langfuse.com/docs/metrics/features/metrics-api - - The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - - Within each batch, there can be multiple events. - Each event has a type, an id, a timestamp, metadata and a body. - Internally, we refer to this as the "event envelope" as it tells us something about the event but not the trace. - We use the event id within this envelope to deduplicate messages to avoid processing the same event twice, i.e. the event id should be unique per request. - The event.body.id is the ID of the actual trace and will be used for updates and will be visible within the Langfuse App. - I.e. if you want to update a trace, you'd use the same body id, but separate event IDs. - - Notes: - - Introduction to data model: https://langfuse.com/docs/observability/data-model - - Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly. - - The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors. - - Parameters - ---------- - batch : typing.Sequence[IngestionEvent] - Batch of events to be ingested, discriminated by attribute `type`. From November 16, 2026 on Langfuse Cloud, only `score-create` events are accepted; all other event types are rejected. - - metadata : typing.Optional[typing.Any] - Optional. Metadata field used by the Langfuse SDKs for debugging. - - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[IngestionResponse] - """ - _response = await self._client_wrapper.httpx_client.request( - "api/public/ingestion", - method="POST", - json={ - "batch": convert_and_respect_annotation_metadata( - object_=batch, - annotation=typing.Sequence[IngestionEvent], - direction="write", - ), - "metadata": metadata, - }, - request_options=request_options, - omit=OMIT, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - IngestionResponse, - parse_obj_as( - type_=IngestionResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 400: - raise Error( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 403: - raise AccessDeniedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 405: - raise MethodNotAllowedError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - if _response.status_code == 404: - raise NotFoundError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response.text, - ) - raise ApiError( - status_code=_response.status_code, - headers=dict(_response.headers), - body=_response_json, - ) diff --git a/langfuse/api/ingestion/types/__init__.py b/langfuse/api/ingestion/types/__init__.py deleted file mode 100644 index 4fd3b0e7e..000000000 --- a/langfuse/api/ingestion/types/__init__.py +++ /dev/null @@ -1,160 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -# isort: skip_file - -import typing -from importlib import import_module - -if typing.TYPE_CHECKING: - from .base_event import BaseEvent - from .create_event_body import CreateEventBody - from .create_event_event import CreateEventEvent - from .create_generation_body import CreateGenerationBody - from .create_generation_event import CreateGenerationEvent - from .create_observation_event import CreateObservationEvent - from .create_span_body import CreateSpanBody - from .create_span_event import CreateSpanEvent - from .ingestion_error import IngestionError - from .ingestion_event import ( - IngestionEvent, - IngestionEvent_EventCreate, - IngestionEvent_GenerationCreate, - IngestionEvent_GenerationUpdate, - IngestionEvent_ObservationCreate, - IngestionEvent_ObservationUpdate, - IngestionEvent_ScoreCreate, - IngestionEvent_SpanCreate, - IngestionEvent_SpanUpdate, - IngestionEvent_TraceCreate, - ) - from .ingestion_response import IngestionResponse - from .ingestion_success import IngestionSuccess - from .ingestion_usage import IngestionUsage - from .observation_body import ObservationBody - from .observation_type import ObservationType - from .open_ai_completion_usage_schema import OpenAiCompletionUsageSchema - from .open_ai_response_usage_schema import OpenAiResponseUsageSchema - from .open_ai_usage import OpenAiUsage - from .optional_observation_body import OptionalObservationBody - from .score_body import ScoreBody - from .score_event import ScoreEvent - from .trace_body import TraceBody - from .trace_event import TraceEvent - from .update_event_body import UpdateEventBody - from .update_generation_body import UpdateGenerationBody - from .update_generation_event import UpdateGenerationEvent - from .update_observation_event import UpdateObservationEvent - from .update_span_body import UpdateSpanBody - from .update_span_event import UpdateSpanEvent - from .usage_details import UsageDetails -_dynamic_imports: typing.Dict[str, str] = { - "BaseEvent": ".base_event", - "CreateEventBody": ".create_event_body", - "CreateEventEvent": ".create_event_event", - "CreateGenerationBody": ".create_generation_body", - "CreateGenerationEvent": ".create_generation_event", - "CreateObservationEvent": ".create_observation_event", - "CreateSpanBody": ".create_span_body", - "CreateSpanEvent": ".create_span_event", - "IngestionError": ".ingestion_error", - "IngestionEvent": ".ingestion_event", - "IngestionEvent_EventCreate": ".ingestion_event", - "IngestionEvent_GenerationCreate": ".ingestion_event", - "IngestionEvent_GenerationUpdate": ".ingestion_event", - "IngestionEvent_ObservationCreate": ".ingestion_event", - "IngestionEvent_ObservationUpdate": ".ingestion_event", - "IngestionEvent_ScoreCreate": ".ingestion_event", - "IngestionEvent_SpanCreate": ".ingestion_event", - "IngestionEvent_SpanUpdate": ".ingestion_event", - "IngestionEvent_TraceCreate": ".ingestion_event", - "IngestionResponse": ".ingestion_response", - "IngestionSuccess": ".ingestion_success", - "IngestionUsage": ".ingestion_usage", - "ObservationBody": ".observation_body", - "ObservationType": ".observation_type", - "OpenAiCompletionUsageSchema": ".open_ai_completion_usage_schema", - "OpenAiResponseUsageSchema": ".open_ai_response_usage_schema", - "OpenAiUsage": ".open_ai_usage", - "OptionalObservationBody": ".optional_observation_body", - "ScoreBody": ".score_body", - "ScoreEvent": ".score_event", - "TraceBody": ".trace_body", - "TraceEvent": ".trace_event", - "UpdateEventBody": ".update_event_body", - "UpdateGenerationBody": ".update_generation_body", - "UpdateGenerationEvent": ".update_generation_event", - "UpdateObservationEvent": ".update_observation_event", - "UpdateSpanBody": ".update_span_body", - "UpdateSpanEvent": ".update_span_event", - "UsageDetails": ".usage_details", -} - - -def __getattr__(attr_name: str) -> typing.Any: - module_name = _dynamic_imports.get(attr_name) - if module_name is None: - raise AttributeError( - f"No {attr_name} found in _dynamic_imports for module name -> {__name__}" - ) - try: - module = import_module(module_name, __package__) - if module_name == f".{attr_name}": - return module - else: - return getattr(module, attr_name) - except ImportError as e: - raise ImportError( - f"Failed to import {attr_name} from {module_name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to get {attr_name} from {module_name}: {e}" - ) from e - - -def __dir__(): - lazy_attrs = list(_dynamic_imports.keys()) - return sorted(lazy_attrs) - - -__all__ = [ - "BaseEvent", - "CreateEventBody", - "CreateEventEvent", - "CreateGenerationBody", - "CreateGenerationEvent", - "CreateObservationEvent", - "CreateSpanBody", - "CreateSpanEvent", - "IngestionError", - "IngestionEvent", - "IngestionEvent_EventCreate", - "IngestionEvent_GenerationCreate", - "IngestionEvent_GenerationUpdate", - "IngestionEvent_ObservationCreate", - "IngestionEvent_ObservationUpdate", - "IngestionEvent_ScoreCreate", - "IngestionEvent_SpanCreate", - "IngestionEvent_SpanUpdate", - "IngestionEvent_TraceCreate", - "IngestionResponse", - "IngestionSuccess", - "IngestionUsage", - "ObservationBody", - "ObservationType", - "OpenAiCompletionUsageSchema", - "OpenAiResponseUsageSchema", - "OpenAiUsage", - "OptionalObservationBody", - "ScoreBody", - "ScoreEvent", - "TraceBody", - "TraceEvent", - "UpdateEventBody", - "UpdateGenerationBody", - "UpdateGenerationEvent", - "UpdateObservationEvent", - "UpdateSpanBody", - "UpdateSpanEvent", - "UsageDetails", -] diff --git a/langfuse/api/ingestion/types/base_event.py b/langfuse/api/ingestion/types/base_event.py deleted file mode 100644 index 70c6bcfa4..000000000 --- a/langfuse/api/ingestion/types/base_event.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class BaseEvent(UniversalBaseModel): - id: str = pydantic.Field() - """ - UUID v4 that identifies the event - """ - - timestamp: str = pydantic.Field() - """ - Datetime (ISO 8601) of event creation in client. Should be as close to actual event creation in client as possible, this timestamp will be used for ordering of events in future release. Resolution: milliseconds (required), microseconds (optimal). - """ - - metadata: typing.Optional[typing.Any] = pydantic.Field(default=None) - """ - Optional. Metadata field used by the Langfuse SDKs for debugging. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_event_body.py b/langfuse/api/ingestion/types/create_event_body.py deleted file mode 100644 index 0473d8a0d..000000000 --- a/langfuse/api/ingestion/types/create_event_body.py +++ /dev/null @@ -1,14 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .optional_observation_body import OptionalObservationBody - - -class CreateEventBody(OptionalObservationBody): - id: typing.Optional[str] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_event_event.py b/langfuse/api/ingestion/types/create_event_event.py deleted file mode 100644 index a0b80a166..000000000 --- a/langfuse/api/ingestion/types/create_event_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .create_event_body import CreateEventBody - - -class CreateEventEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: CreateEventBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_generation_body.py b/langfuse/api/ingestion/types/create_generation_body.py deleted file mode 100644 index 72cb57116..000000000 --- a/langfuse/api/ingestion/types/create_generation_body.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...commons.types.map_value import MapValue -from ...core.serialization import FieldMetadata -from .create_span_body import CreateSpanBody -from .ingestion_usage import IngestionUsage -from .usage_details import UsageDetails - - -class CreateGenerationBody(CreateSpanBody): - completion_start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="completionStartTime") - ] = None - model: typing.Optional[str] = None - model_parameters: typing_extensions.Annotated[ - typing.Optional[typing.Dict[str, MapValue]], - FieldMetadata(alias="modelParameters"), - ] = None - usage: typing.Optional[IngestionUsage] = None - usage_details: typing_extensions.Annotated[ - typing.Optional[UsageDetails], FieldMetadata(alias="usageDetails") - ] = None - cost_details: typing_extensions.Annotated[ - typing.Optional[typing.Dict[str, float]], FieldMetadata(alias="costDetails") - ] = None - prompt_name: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="promptName") - ] = None - prompt_version: typing_extensions.Annotated[ - typing.Optional[int], FieldMetadata(alias="promptVersion") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_generation_event.py b/langfuse/api/ingestion/types/create_generation_event.py deleted file mode 100644 index 9ee974a10..000000000 --- a/langfuse/api/ingestion/types/create_generation_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .create_generation_body import CreateGenerationBody - - -class CreateGenerationEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: CreateGenerationBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_observation_event.py b/langfuse/api/ingestion/types/create_observation_event.py deleted file mode 100644 index 68f0c73f1..000000000 --- a/langfuse/api/ingestion/types/create_observation_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .observation_body import ObservationBody - - -class CreateObservationEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: ObservationBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_span_body.py b/langfuse/api/ingestion/types/create_span_body.py deleted file mode 100644 index 7a47d9748..000000000 --- a/langfuse/api/ingestion/types/create_span_body.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .create_event_body import CreateEventBody - - -class CreateSpanBody(CreateEventBody): - end_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="endTime") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/create_span_event.py b/langfuse/api/ingestion/types/create_span_event.py deleted file mode 100644 index 9b40bcf6b..000000000 --- a/langfuse/api/ingestion/types/create_span_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .create_span_body import CreateSpanBody - - -class CreateSpanEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: CreateSpanBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/ingestion_error.py b/langfuse/api/ingestion/types/ingestion_error.py deleted file mode 100644 index 2391d95eb..000000000 --- a/langfuse/api/ingestion/types/ingestion_error.py +++ /dev/null @@ -1,17 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class IngestionError(UniversalBaseModel): - id: str - status: int - message: typing.Optional[str] = None - error: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/ingestion_event.py b/langfuse/api/ingestion/types/ingestion_event.py deleted file mode 100644 index 3ee88e030..000000000 --- a/langfuse/api/ingestion/types/ingestion_event.py +++ /dev/null @@ -1,141 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -from __future__ import annotations - -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from .create_event_body import CreateEventBody -from .create_generation_body import CreateGenerationBody -from .create_span_body import CreateSpanBody -from .observation_body import ObservationBody -from .score_body import ScoreBody -from .trace_body import TraceBody -from .update_generation_body import UpdateGenerationBody -from .update_span_body import UpdateSpanBody - - -class IngestionEvent_TraceCreate(UniversalBaseModel): - type: typing.Literal["trace-create"] = "trace-create" - body: TraceBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_ScoreCreate(UniversalBaseModel): - type: typing.Literal["score-create"] = "score-create" - body: ScoreBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_SpanCreate(UniversalBaseModel): - type: typing.Literal["span-create"] = "span-create" - body: CreateSpanBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_SpanUpdate(UniversalBaseModel): - type: typing.Literal["span-update"] = "span-update" - body: UpdateSpanBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_GenerationCreate(UniversalBaseModel): - type: typing.Literal["generation-create"] = "generation-create" - body: CreateGenerationBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_GenerationUpdate(UniversalBaseModel): - type: typing.Literal["generation-update"] = "generation-update" - body: UpdateGenerationBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_EventCreate(UniversalBaseModel): - type: typing.Literal["event-create"] = "event-create" - body: CreateEventBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_ObservationCreate(UniversalBaseModel): - type: typing.Literal["observation-create"] = "observation-create" - body: ObservationBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -class IngestionEvent_ObservationUpdate(UniversalBaseModel): - type: typing.Literal["observation-update"] = "observation-update" - body: ObservationBody - id: str - timestamp: str - metadata: typing.Optional[typing.Any] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) - - -IngestionEvent = typing_extensions.Annotated[ - typing.Union[ - IngestionEvent_TraceCreate, - IngestionEvent_ScoreCreate, - IngestionEvent_SpanCreate, - IngestionEvent_SpanUpdate, - IngestionEvent_GenerationCreate, - IngestionEvent_GenerationUpdate, - IngestionEvent_EventCreate, - IngestionEvent_ObservationCreate, - IngestionEvent_ObservationUpdate, - ], - pydantic.Field(discriminator="type"), -] diff --git a/langfuse/api/ingestion/types/ingestion_response.py b/langfuse/api/ingestion/types/ingestion_response.py deleted file mode 100644 index 063b9a773..000000000 --- a/langfuse/api/ingestion/types/ingestion_response.py +++ /dev/null @@ -1,23 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ...commons.types.deprecation import Deprecation -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .ingestion_error import IngestionError -from .ingestion_success import IngestionSuccess - - -class IngestionResponse(UniversalBaseModel): - successes: typing.List[IngestionSuccess] - errors: typing.List[IngestionError] - deprecation: typing_extensions.Annotated[ - typing.Optional[Deprecation], FieldMetadata(alias="_deprecation") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/ingestion_success.py b/langfuse/api/ingestion/types/ingestion_success.py deleted file mode 100644 index e7894797a..000000000 --- a/langfuse/api/ingestion/types/ingestion_success.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class IngestionSuccess(UniversalBaseModel): - id: str - status: int - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/ingestion_usage.py b/langfuse/api/ingestion/types/ingestion_usage.py deleted file mode 100644 index 8b3f67103..000000000 --- a/langfuse/api/ingestion/types/ingestion_usage.py +++ /dev/null @@ -1,8 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from ...commons.types.usage import Usage -from .open_ai_usage import OpenAiUsage - -IngestionUsage = typing.Union[Usage, OpenAiUsage] diff --git a/langfuse/api/ingestion/types/observation_body.py b/langfuse/api/ingestion/types/observation_body.py deleted file mode 100644 index e989a768f..000000000 --- a/langfuse/api/ingestion/types/observation_body.py +++ /dev/null @@ -1,53 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...commons.types.map_value import MapValue -from ...commons.types.observation_level import ObservationLevel -from ...commons.types.usage import Usage -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata -from .observation_type import ObservationType - - -class ObservationBody(UniversalBaseModel): - id: typing.Optional[str] = None - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - type: ObservationType - name: typing.Optional[str] = None - start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="startTime") - ] = None - end_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="endTime") - ] = None - completion_start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="completionStartTime") - ] = None - model: typing.Optional[str] = None - model_parameters: typing_extensions.Annotated[ - typing.Optional[typing.Dict[str, MapValue]], - FieldMetadata(alias="modelParameters"), - ] = None - input: typing.Optional[typing.Any] = None - version: typing.Optional[str] = None - metadata: typing.Optional[typing.Any] = None - output: typing.Optional[typing.Any] = None - usage: typing.Optional[Usage] = None - level: typing.Optional[ObservationLevel] = None - status_message: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="statusMessage") - ] = None - parent_observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="parentObservationId") - ] = None - environment: typing.Optional[str] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/observation_type.py b/langfuse/api/ingestion/types/observation_type.py deleted file mode 100644 index f769e34cf..000000000 --- a/langfuse/api/ingestion/types/observation_type.py +++ /dev/null @@ -1,54 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from ...core import enum - -T_Result = typing.TypeVar("T_Result") - - -class ObservationType(enum.StrEnum): - SPAN = "SPAN" - GENERATION = "GENERATION" - EVENT = "EVENT" - AGENT = "AGENT" - TOOL = "TOOL" - CHAIN = "CHAIN" - RETRIEVER = "RETRIEVER" - EVALUATOR = "EVALUATOR" - EMBEDDING = "EMBEDDING" - GUARDRAIL = "GUARDRAIL" - - def visit( - self, - span: typing.Callable[[], T_Result], - generation: typing.Callable[[], T_Result], - event: typing.Callable[[], T_Result], - agent: typing.Callable[[], T_Result], - tool: typing.Callable[[], T_Result], - chain: typing.Callable[[], T_Result], - retriever: typing.Callable[[], T_Result], - evaluator: typing.Callable[[], T_Result], - embedding: typing.Callable[[], T_Result], - guardrail: typing.Callable[[], T_Result], - ) -> T_Result: - if self is ObservationType.SPAN: - return span() - if self is ObservationType.GENERATION: - return generation() - if self is ObservationType.EVENT: - return event() - if self is ObservationType.AGENT: - return agent() - if self is ObservationType.TOOL: - return tool() - if self is ObservationType.CHAIN: - return chain() - if self is ObservationType.RETRIEVER: - return retriever() - if self is ObservationType.EVALUATOR: - return evaluator() - if self is ObservationType.EMBEDDING: - return embedding() - if self is ObservationType.GUARDRAIL: - return guardrail() diff --git a/langfuse/api/ingestion/types/open_ai_completion_usage_schema.py b/langfuse/api/ingestion/types/open_ai_completion_usage_schema.py deleted file mode 100644 index 292c51e79..000000000 --- a/langfuse/api/ingestion/types/open_ai_completion_usage_schema.py +++ /dev/null @@ -1,26 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class OpenAiCompletionUsageSchema(UniversalBaseModel): - """ - OpenAI Usage schema from (Chat-)Completion APIs - """ - - prompt_tokens: int - completion_tokens: int - total_tokens: int - prompt_tokens_details: typing.Optional[typing.Dict[str, typing.Optional[int]]] = ( - None - ) - completion_tokens_details: typing.Optional[ - typing.Dict[str, typing.Optional[int]] - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/open_ai_response_usage_schema.py b/langfuse/api/ingestion/types/open_ai_response_usage_schema.py deleted file mode 100644 index 93cbc7dba..000000000 --- a/langfuse/api/ingestion/types/open_ai_response_usage_schema.py +++ /dev/null @@ -1,24 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ...core.pydantic_utilities import UniversalBaseModel - - -class OpenAiResponseUsageSchema(UniversalBaseModel): - """ - OpenAI Usage schema from Response API - """ - - input_tokens: int - output_tokens: int - total_tokens: int - input_tokens_details: typing.Optional[typing.Dict[str, typing.Optional[int]]] = None - output_tokens_details: typing.Optional[typing.Dict[str, typing.Optional[int]]] = ( - None - ) - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/optional_observation_body.py b/langfuse/api/ingestion/types/optional_observation_body.py deleted file mode 100644 index f2aaf9b6d..000000000 --- a/langfuse/api/ingestion/types/optional_observation_body.py +++ /dev/null @@ -1,36 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...commons.types.observation_level import ObservationLevel -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class OptionalObservationBody(UniversalBaseModel): - trace_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="traceId") - ] = None - name: typing.Optional[str] = None - start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="startTime") - ] = None - metadata: typing.Optional[typing.Any] = None - input: typing.Optional[typing.Any] = None - output: typing.Optional[typing.Any] = None - level: typing.Optional[ObservationLevel] = None - status_message: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="statusMessage") - ] = None - parent_observation_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="parentObservationId") - ] = None - version: typing.Optional[str] = None - environment: typing.Optional[str] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/score_event.py b/langfuse/api/ingestion/types/score_event.py deleted file mode 100644 index d0d470899..000000000 --- a/langfuse/api/ingestion/types/score_event.py +++ /dev/null @@ -1,15 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .score_body import ScoreBody - - -class ScoreEvent(BaseEvent): - body: ScoreBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/trace_body.py b/langfuse/api/ingestion/types/trace_body.py deleted file mode 100644 index 7fb2842a0..000000000 --- a/langfuse/api/ingestion/types/trace_body.py +++ /dev/null @@ -1,36 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.pydantic_utilities import UniversalBaseModel -from ...core.serialization import FieldMetadata - - -class TraceBody(UniversalBaseModel): - id: typing.Optional[str] = None - timestamp: typing.Optional[dt.datetime] = None - name: typing.Optional[str] = None - user_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="userId") - ] = None - input: typing.Optional[typing.Any] = None - output: typing.Optional[typing.Any] = None - session_id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="sessionId") - ] = None - release: typing.Optional[str] = None - version: typing.Optional[str] = None - metadata: typing.Optional[typing.Any] = None - tags: typing.Optional[typing.List[str]] = None - environment: typing.Optional[str] = None - public: typing.Optional[bool] = pydantic.Field(default=None) - """ - Make trace publicly accessible via url - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/trace_event.py b/langfuse/api/ingestion/types/trace_event.py deleted file mode 100644 index 3c89ec52b..000000000 --- a/langfuse/api/ingestion/types/trace_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .trace_body import TraceBody - - -class TraceEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: TraceBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_event_body.py b/langfuse/api/ingestion/types/update_event_body.py deleted file mode 100644 index 055f66f08..000000000 --- a/langfuse/api/ingestion/types/update_event_body.py +++ /dev/null @@ -1,14 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .optional_observation_body import OptionalObservationBody - - -class UpdateEventBody(OptionalObservationBody): - id: str - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_generation_body.py b/langfuse/api/ingestion/types/update_generation_body.py deleted file mode 100644 index 1d453e759..000000000 --- a/langfuse/api/ingestion/types/update_generation_body.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...commons.types.map_value import MapValue -from ...core.serialization import FieldMetadata -from .ingestion_usage import IngestionUsage -from .update_span_body import UpdateSpanBody -from .usage_details import UsageDetails - - -class UpdateGenerationBody(UpdateSpanBody): - completion_start_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="completionStartTime") - ] = None - model: typing.Optional[str] = None - model_parameters: typing_extensions.Annotated[ - typing.Optional[typing.Dict[str, MapValue]], - FieldMetadata(alias="modelParameters"), - ] = None - usage: typing.Optional[IngestionUsage] = None - prompt_name: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="promptName") - ] = None - usage_details: typing_extensions.Annotated[ - typing.Optional[UsageDetails], FieldMetadata(alias="usageDetails") - ] = None - cost_details: typing_extensions.Annotated[ - typing.Optional[typing.Dict[str, float]], FieldMetadata(alias="costDetails") - ] = None - prompt_version: typing_extensions.Annotated[ - typing.Optional[int], FieldMetadata(alias="promptVersion") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_generation_event.py b/langfuse/api/ingestion/types/update_generation_event.py deleted file mode 100644 index 359c0934b..000000000 --- a/langfuse/api/ingestion/types/update_generation_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .update_generation_body import UpdateGenerationBody - - -class UpdateGenerationEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: UpdateGenerationBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_observation_event.py b/langfuse/api/ingestion/types/update_observation_event.py deleted file mode 100644 index 0f6f8786d..000000000 --- a/langfuse/api/ingestion/types/update_observation_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .observation_body import ObservationBody - - -class UpdateObservationEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: ObservationBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_span_body.py b/langfuse/api/ingestion/types/update_span_body.py deleted file mode 100644 index f094b7cdb..000000000 --- a/langfuse/api/ingestion/types/update_span_body.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -import typing_extensions -from ...core.serialization import FieldMetadata -from .update_event_body import UpdateEventBody - - -class UpdateSpanBody(UpdateEventBody): - end_time: typing_extensions.Annotated[ - typing.Optional[dt.datetime], FieldMetadata(alias="endTime") - ] = None - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/update_span_event.py b/langfuse/api/ingestion/types/update_span_event.py deleted file mode 100644 index a2138ced6..000000000 --- a/langfuse/api/ingestion/types/update_span_event.py +++ /dev/null @@ -1,19 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from .base_event import BaseEvent -from .update_span_body import UpdateSpanBody - - -class UpdateSpanEvent(BaseEvent): - """ - Sunset warning: this is the deprecated Langfuse v3 ingestion API. It is shut down on November 16, 2026, except for score events: from that date it accepts only `score-create` events and rejects all other event types, including this one. To write scores, prefer `POST /api/public/scores`; see the [Scores API docs](https://langfuse.com/docs/api-and-data-platform/features/scores-api). To write traces and observations, always prefer upgrading to the current Python and JS SDKs. If you use custom auto-instrumentation, send them to the OpenTelemetry endpoint (`POST /api/public/otel/v1/traces`), for example with curl; see the [OpenTelemetry integration docs](https://langfuse.com/integrations/native/opentelemetry). To read data back, use the v4 read APIs; see the [Observations API docs](https://langfuse.com/docs/api-and-data-platform/features/observations-api) and the [Metrics API docs](https://langfuse.com/docs/metrics/features/metrics-api). The only path to live data is OpenTelemetry ingestion combined with `GET /api/public/v2/observations` and `GET /api/public/v2/metrics`. All other public APIs may have data delays of several minutes. - """ - - body: UpdateSpanBody - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - extra="allow", frozen=True - ) diff --git a/langfuse/api/ingestion/types/usage_details.py b/langfuse/api/ingestion/types/usage_details.py deleted file mode 100644 index beb98cbd0..000000000 --- a/langfuse/api/ingestion/types/usage_details.py +++ /dev/null @@ -1,10 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -from .open_ai_completion_usage_schema import OpenAiCompletionUsageSchema -from .open_ai_response_usage_schema import OpenAiResponseUsageSchema - -UsageDetails = typing.Union[ - typing.Dict[str, int], OpenAiCompletionUsageSchema, OpenAiResponseUsageSchema -] diff --git a/langfuse/api/scores/__init__.py b/langfuse/api/scores/__init__.py index a92174549..0fe558ea3 100644 --- a/langfuse/api/scores/__init__.py +++ b/langfuse/api/scores/__init__.py @@ -21,6 +21,7 @@ GetScoresV3Meta, GetScoresV3Response, NumericScoreV3, + ScoreBody, ScoreSubjectExperimentV3, ScoreSubjectObservationV3, ScoreSubjectSessionV3, @@ -53,6 +54,7 @@ "GetScoresV3Meta": ".types", "GetScoresV3Response": ".types", "NumericScoreV3": ".types", + "ScoreBody": ".types", "ScoreSubjectExperimentV3": ".types", "ScoreSubjectObservationV3": ".types", "ScoreSubjectSessionV3": ".types", @@ -114,6 +116,7 @@ def __dir__(): "GetScoresV3Meta", "GetScoresV3Response", "NumericScoreV3", + "ScoreBody", "ScoreSubjectExperimentV3", "ScoreSubjectObservationV3", "ScoreSubjectSessionV3", diff --git a/langfuse/api/scores/types/__init__.py b/langfuse/api/scores/types/__init__.py index d2578c4b5..9a2be8f5c 100644 --- a/langfuse/api/scores/types/__init__.py +++ b/langfuse/api/scores/types/__init__.py @@ -20,6 +20,7 @@ from .get_scores_v3meta import GetScoresV3Meta from .get_scores_v3response import GetScoresV3Response from .numeric_score_v3 import NumericScoreV3 + from .score_body import ScoreBody from .score_subject_experiment_v3 import ScoreSubjectExperimentV3 from .score_subject_observation_v3 import ScoreSubjectObservationV3 from .score_subject_session_v3 import ScoreSubjectSessionV3 @@ -55,6 +56,7 @@ "GetScoresV3Meta": ".get_scores_v3meta", "GetScoresV3Response": ".get_scores_v3response", "NumericScoreV3": ".numeric_score_v3", + "ScoreBody": ".score_body", "ScoreSubjectExperimentV3": ".score_subject_experiment_v3", "ScoreSubjectObservationV3": ".score_subject_observation_v3", "ScoreSubjectSessionV3": ".score_subject_session_v3", @@ -116,6 +118,7 @@ def __dir__(): "GetScoresV3Meta", "GetScoresV3Response", "NumericScoreV3", + "ScoreBody", "ScoreSubjectExperimentV3", "ScoreSubjectObservationV3", "ScoreSubjectSessionV3", diff --git a/langfuse/api/ingestion/types/score_body.py b/langfuse/api/scores/types/score_body.py similarity index 98% rename from langfuse/api/ingestion/types/score_body.py rename to langfuse/api/scores/types/score_body.py index d9a32b6a7..ec10ba7cf 100644 --- a/langfuse/api/ingestion/types/score_body.py +++ b/langfuse/api/scores/types/score_body.py @@ -14,7 +14,7 @@ class ScoreBody(UniversalBaseModel): """ Examples -------- - from langfuse.ingestion import ScoreBody + from langfuse.scores import ScoreBody ScoreBody( name="novelty",