From b54b0c87dbc02794a2ae77fcdf1308317f53131e Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:56:46 -0700 Subject: [PATCH 1/2] Add proper TypedDict types and TypeAdapter validation to datasets API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace untyped dict[str, Any] return types with proper TypedDicts derived from backend endpoint definitions. Add Pydantic TypeAdapters to validate/coerce API responses (string UUIDs → UUID, string datetimes → datetime). Add CaseData TypedDict for add_cases parameter typing. --- logfire/experimental/api_client.py | 201 +++++++++++++++++----- logfire/experimental/datasets/__init__.py | 15 ++ tests/test_datasets_client.py | 141 +++++++++------ 3 files changed, 265 insertions(+), 92 deletions(-) diff --git a/logfire/experimental/api_client.py b/logfire/experimental/api_client.py index 39beda8ac..50c393bf4 100644 --- a/logfire/experimental/api_client.py +++ b/logfire/experimental/api_client.py @@ -50,11 +50,13 @@ class MyOutput: import re from collections.abc import Sequence +from datetime import datetime from types import TracebackType from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload +from uuid import UUID from pydantic import TypeAdapter -from typing_extensions import Self +from typing_extensions import NotRequired, Self, TypedDict from logfire._internal.config import get_base_url_from_token @@ -84,6 +86,121 @@ class MyOutput: _DATASET_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$') +# --- Response TypedDicts --- +# Pydantic TypeAdapters are used to validate responses, coercing +# string UUIDs/datetimes from JSON into proper Python types. + + +class EvaluatorSpec(TypedDict): + """An evaluator specification with a name and optional arguments.""" + + name: str + arguments: dict[str, Any] | None + + +class DatasetSummary(TypedDict): + """Summary of a dataset, returned by :meth:`LogfireAPIClient.list_datasets`.""" + + id: UUID + project_id: UUID + name: str + description: str | None + guidance: str | None + ai_managed_guidance: bool + case_count: int + created_at: datetime + updated_at: datetime + created_by_name: str | None + updated_by_name: str | None + + +class DatasetDetail(TypedDict): + """Full dataset details. + + Returned by :meth:`LogfireAPIClient.get_dataset`, + :meth:`LogfireAPIClient.create_dataset`, and :meth:`LogfireAPIClient.update_dataset`. + """ + + id: UUID + project_id: UUID + name: str + description: str | None + input_schema: dict[str, Any] | None + output_schema: dict[str, Any] | None + metadata_schema: dict[str, Any] | None + guidance: str | None + ai_managed_guidance: bool + case_count: int + created_at: datetime + updated_at: datetime + created_by: UUID | None + + +class CaseDetail(TypedDict): + """Full case details, returned by case operations like :meth:`LogfireAPIClient.get_case`.""" + + id: UUID + dataset_id: UUID + name: str | None + inputs: Any + expected_output: Any + metadata: Any + evaluators: list[EvaluatorSpec] | None + source_trace_id: str | None + source_span_id: str | None + tags: list[str] | None + version: int + created_at: datetime + created_by: UUID | None + updated_at: datetime + updated_by: UUID | None + + +class CaseData(TypedDict): + """Data for creating a case via :meth:`LogfireAPIClient.add_cases`. + + Only ``inputs`` is required; all other fields are optional. + """ + + inputs: Any + name: NotRequired[str | None] + expected_output: NotRequired[Any] + metadata: NotRequired[Any] + evaluators: NotRequired[list[EvaluatorSpec] | None] + tags: NotRequired[list[str] | None] + + +class ExportedCase(TypedDict): + """A case in pydantic-evals compatible format, part of :class:`ExportedDataset`.""" + + name: str | None + inputs: Any + metadata: Any + expected_output: Any + evaluators: list[EvaluatorSpec] | None + + +class ExportedDataset(TypedDict): + """Dataset export in pydantic-evals compatible format. + + Returned by :meth:`LogfireAPIClient.export_dataset` when called without type arguments. + Compatible with ``pydantic_evals.Dataset.from_dict()``. + """ + + name: str | None + cases: list[ExportedCase] + evaluators: list[EvaluatorSpec] + + +# --- TypeAdapters for response validation --- + +_dataset_summary_list_adapter: TypeAdapter[list[DatasetSummary]] = TypeAdapter(list[DatasetSummary]) +_dataset_detail_adapter: TypeAdapter[DatasetDetail] = TypeAdapter(DatasetDetail) +_case_detail_list_adapter: TypeAdapter[list[CaseDetail]] = TypeAdapter(list[CaseDetail]) +_case_detail_adapter: TypeAdapter[CaseDetail] = TypeAdapter(CaseDetail) +_exported_dataset_adapter: TypeAdapter[ExportedDataset] = TypeAdapter(ExportedDataset) + + def _validate_dataset_name(name: str) -> None: """Validate that a dataset name is URL-safe.""" if not _DATASET_NAME_RE.match(name): @@ -323,16 +440,16 @@ def __exit__( # --- Dataset operations --- - def list_datasets(self) -> list[dict[str, Any]]: + def list_datasets(self) -> list[DatasetSummary]: """List all datasets in the project. Returns: List of dataset summaries with id, name, description, case_count, etc. """ response = self.client.get('/v1/datasets/') - return self._handle_response(response) + return _dataset_summary_list_adapter.validate_python(self._handle_response(response)) - def get_dataset(self, id_or_name: str) -> dict[str, Any]: + def get_dataset(self, id_or_name: str) -> DatasetDetail: """Get a dataset by ID or name. Args: @@ -345,7 +462,7 @@ def get_dataset(self, id_or_name: str) -> dict[str, Any]: DatasetNotFoundError: If the dataset does not exist. """ response = self.client.get(f'/v1/datasets/{id_or_name}/') - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) def create_dataset( self, @@ -357,7 +474,7 @@ def create_dataset( description: str | None = None, guidance: str | None = None, ai_managed_guidance: bool = False, - ) -> dict[str, Any]: + ) -> DatasetDetail: """Create a new dataset with optional type schemas. Args: @@ -413,7 +530,7 @@ class MyOutput: data['ai_managed_guidance'] = ai_managed_guidance response = self.client.post('/v1/datasets/', json=data) - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) def update_dataset( self, @@ -426,7 +543,7 @@ def update_dataset( description: str | None = _UNSET, guidance: str | None = _UNSET, ai_managed_guidance: bool | None = None, - ) -> dict[str, Any]: + ) -> DatasetDetail: """Update an existing dataset. Args: @@ -463,7 +580,7 @@ def update_dataset( data['ai_managed_guidance'] = ai_managed_guidance response = self.client.patch(f'/v1/datasets/{id_or_name}/', json=data) - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) def delete_dataset(self, id_or_name: str) -> None: """Delete a dataset and all its cases. @@ -479,7 +596,7 @@ def delete_dataset(self, id_or_name: str) -> None: # --- Case operations --- - def list_cases(self, dataset_id_or_name: str, *, tags: list[str] | None = None) -> list[dict[str, Any]]: + def list_cases(self, dataset_id_or_name: str, *, tags: list[str] | None = None) -> list[CaseDetail]: """List all cases in a dataset. Args: @@ -496,9 +613,9 @@ def list_cases(self, dataset_id_or_name: str, *, tags: list[str] | None = None) if tags is not None: params['tags'] = tags response = self.client.get(f'/v1/datasets/{dataset_id_or_name}/cases/', params=params) - return self._handle_response(response) + return _case_detail_list_adapter.validate_python(self._handle_response(response)) - def get_case(self, dataset_id_or_name: str, case_id: str) -> dict[str, Any]: + def get_case(self, dataset_id_or_name: str, case_id: str) -> CaseDetail: """Get a specific case from a dataset. Args: @@ -513,16 +630,16 @@ def get_case(self, dataset_id_or_name: str, case_id: str) -> dict[str, Any]: CaseNotFoundError: If the case does not exist. """ response = self.client.get(f'/v1/datasets/{dataset_id_or_name}/cases/{case_id}/') - return self._handle_response(response, is_case_endpoint=True) + return _case_detail_adapter.validate_python(self._handle_response(response, is_case_endpoint=True)) def add_cases( self, dataset_id_or_name: str, - cases: Sequence[Case[InputsT, OutputT, MetadataT]] | Sequence[dict[str, Any]], + cases: Sequence[Case[InputsT, OutputT, MetadataT]] | Sequence[CaseData], *, tags: list[str] | None = None, on_conflict: str = 'update', - ) -> list[dict[str, Any]]: + ) -> list[CaseDetail]: """Add cases to a dataset. Accepts either pydantic-evals Case objects or plain dicts. @@ -571,7 +688,7 @@ def add_cases( json={'cases': serialized_cases}, params={'on_conflict': on_conflict}, ) - return self._handle_response(response) + return _case_detail_list_adapter.validate_python(self._handle_response(response)) def update_case( self, @@ -584,7 +701,7 @@ def update_case( metadata: Any | None = _UNSET, evaluators: Sequence[Evaluator[Any, Any, Any]] | None = _UNSET, tags: list[str] | None = _UNSET, - ) -> dict[str, Any]: + ) -> CaseDetail: """Update an existing case. Args: @@ -627,7 +744,7 @@ def update_case( data['tags'] = tags response = self.client.patch(f'/v1/datasets/{dataset_id_or_name}/cases/{case_id}/', json=data) - return self._handle_response(response, is_case_endpoint=True) + return _case_detail_adapter.validate_python(self._handle_response(response, is_case_endpoint=True)) def delete_case(self, dataset_id_or_name: str, case_id: str) -> None: """Delete a case from a dataset. @@ -646,7 +763,7 @@ def delete_case(self, dataset_id_or_name: str, case_id: str) -> None: # --- Export/Import operations --- @overload - def export_dataset(self, id_or_name: str) -> dict[str, Any]: ... + def export_dataset(self, id_or_name: str) -> ExportedDataset: ... @overload def export_dataset( @@ -667,7 +784,7 @@ def export_dataset( metadata_type: type[MetadataT] | None = None, *, custom_evaluator_types: Sequence[type[Evaluator[Any, Any, Any]]] = (), - ) -> Dataset[InputsT, OutputT, MetadataT] | dict[str, Any]: + ) -> Dataset[InputsT, OutputT, MetadataT] | ExportedDataset: """Export a dataset, optionally as a typed pydantic-evals Dataset. When called with type arguments, returns a `pydantic_evals.Dataset` with @@ -709,9 +826,9 @@ def export_dataset( response = self.client.get(f'/v1/datasets/{id_or_name}/export/') data = self._handle_response(response) - # If no types provided, return raw dict + # If no types provided, return validated dict if input_type is None: - return data + return _exported_dataset_adapter.validate_python(data) # Convert to typed Dataset using pydantic-evals Dataset, _ = _import_pydantic_evals() @@ -761,15 +878,15 @@ async def __aexit__( ) -> None: await self.client.__aexit__(exc_type, exc_value, traceback) - async def list_datasets(self) -> list[dict[str, Any]]: + async def list_datasets(self) -> list[DatasetSummary]: """List all datasets.""" response = await self.client.get('/v1/datasets/') - return self._handle_response(response) + return _dataset_summary_list_adapter.validate_python(self._handle_response(response)) - async def get_dataset(self, id_or_name: str) -> dict[str, Any]: + async def get_dataset(self, id_or_name: str) -> DatasetDetail: """Get a dataset by ID or name.""" response = await self.client.get(f'/v1/datasets/{id_or_name}/') - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) async def create_dataset( self, @@ -781,7 +898,7 @@ async def create_dataset( description: str | None = None, guidance: str | None = None, ai_managed_guidance: bool = False, - ) -> dict[str, Any]: + ) -> DatasetDetail: """Create a new dataset.""" _validate_dataset_name(name) data: dict[str, Any] = {'name': name} @@ -799,7 +916,7 @@ async def create_dataset( data['ai_managed_guidance'] = ai_managed_guidance response = await self.client.post('/v1/datasets/', json=data) - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) async def update_dataset( self, @@ -812,7 +929,7 @@ async def update_dataset( description: str | None = _UNSET, guidance: str | None = _UNSET, ai_managed_guidance: bool | None = None, - ) -> dict[str, Any]: + ) -> DatasetDetail: """Update an existing dataset.""" data: dict[str, Any] = {} if name is not _UNSET: @@ -832,34 +949,34 @@ async def update_dataset( data['ai_managed_guidance'] = ai_managed_guidance response = await self.client.patch(f'/v1/datasets/{id_or_name}/', json=data) - return self._handle_response(response) + return _dataset_detail_adapter.validate_python(self._handle_response(response)) async def delete_dataset(self, id_or_name: str) -> None: """Delete a dataset.""" response = await self.client.delete(f'/v1/datasets/{id_or_name}/') self._handle_response(response) - async def list_cases(self, dataset_id_or_name: str, *, tags: list[str] | None = None) -> list[dict[str, Any]]: + async def list_cases(self, dataset_id_or_name: str, *, tags: list[str] | None = None) -> list[CaseDetail]: """List all cases in a dataset.""" params: dict[str, Any] = {} if tags is not None: params['tags'] = tags response = await self.client.get(f'/v1/datasets/{dataset_id_or_name}/cases/', params=params) - return self._handle_response(response) + return _case_detail_list_adapter.validate_python(self._handle_response(response)) - async def get_case(self, dataset_id_or_name: str, case_id: str) -> dict[str, Any]: + async def get_case(self, dataset_id_or_name: str, case_id: str) -> CaseDetail: """Get a specific case from a dataset.""" response = await self.client.get(f'/v1/datasets/{dataset_id_or_name}/cases/{case_id}/') - return self._handle_response(response, is_case_endpoint=True) + return _case_detail_adapter.validate_python(self._handle_response(response, is_case_endpoint=True)) async def add_cases( self, dataset_id_or_name: str, - cases: Sequence[Case[InputsT, OutputT, MetadataT]] | Sequence[dict[str, Any]], + cases: Sequence[Case[InputsT, OutputT, MetadataT]] | Sequence[CaseData], *, tags: list[str] | None = None, on_conflict: str = 'update', - ) -> list[dict[str, Any]]: + ) -> list[CaseDetail]: """Add cases to a dataset. Accepts either pydantic-evals Case objects or plain dicts. @@ -882,7 +999,7 @@ async def add_cases( json={'cases': serialized_cases}, params={'on_conflict': on_conflict}, ) - return self._handle_response(response) + return _case_detail_list_adapter.validate_python(self._handle_response(response)) async def update_case( self, @@ -895,7 +1012,7 @@ async def update_case( metadata: Any | None = _UNSET, evaluators: Sequence[Evaluator[Any, Any, Any]] | None = _UNSET, tags: list[str] | None = _UNSET, - ) -> dict[str, Any]: + ) -> CaseDetail: """Update an existing case.""" data: dict[str, Any] = {} if name is not _UNSET: @@ -920,7 +1037,7 @@ async def update_case( data['tags'] = tags response = await self.client.patch(f'/v1/datasets/{dataset_id_or_name}/cases/{case_id}/', json=data) - return self._handle_response(response, is_case_endpoint=True) + return _case_detail_adapter.validate_python(self._handle_response(response, is_case_endpoint=True)) async def delete_case(self, dataset_id_or_name: str, case_id: str) -> None: """Delete a case from a dataset.""" @@ -928,7 +1045,7 @@ async def delete_case(self, dataset_id_or_name: str, case_id: str) -> None: self._handle_response(response, is_case_endpoint=True) @overload - async def export_dataset(self, id_or_name: str) -> dict[str, Any]: ... + async def export_dataset(self, id_or_name: str) -> ExportedDataset: ... @overload async def export_dataset( @@ -949,13 +1066,13 @@ async def export_dataset( metadata_type: type[MetadataT] | None = None, *, custom_evaluator_types: Sequence[type[Evaluator[Any, Any, Any]]] = (), - ) -> Dataset[InputsT, OutputT, MetadataT] | dict[str, Any]: + ) -> Dataset[InputsT, OutputT, MetadataT] | ExportedDataset: """Export a dataset, optionally as a typed pydantic-evals Dataset.""" response = await self.client.get(f'/v1/datasets/{id_or_name}/export/') data = self._handle_response(response) if input_type is None: - return data + return _exported_dataset_adapter.validate_python(data) Dataset, _ = _import_pydantic_evals() typed_dataset_cls: type[Dataset[InputsT, OutputT, MetadataT]] = Dataset[input_type, output_type, metadata_type] # type: ignore diff --git a/logfire/experimental/datasets/__init__.py b/logfire/experimental/datasets/__init__.py index eea6a8abc..ebcd4a780 100644 --- a/logfire/experimental/datasets/__init__.py +++ b/logfire/experimental/datasets/__init__.py @@ -59,9 +59,16 @@ class MyOutput: from logfire.experimental.api_client import ( AsyncLogfireAPIClient, + CaseData, + CaseDetail, CaseNotFoundError, DatasetApiError, + DatasetDetail, DatasetNotFoundError, + DatasetSummary, + EvaluatorSpec, + ExportedCase, + ExportedDataset, LogfireAPIClient, ) @@ -69,6 +76,14 @@ class MyOutput: # Clients 'LogfireAPIClient', 'AsyncLogfireAPIClient', + # Response types + 'DatasetSummary', + 'DatasetDetail', + 'CaseDetail', + 'CaseData', + 'ExportedDataset', + 'ExportedCase', + 'EvaluatorSpec', # Errors 'DatasetNotFoundError', 'CaseNotFoundError', diff --git a/tests/test_datasets_client.py b/tests/test_datasets_client.py index 31d661e0e..b8c162d6a 100644 --- a/tests/test_datasets_client.py +++ b/tests/test_datasets_client.py @@ -25,6 +25,10 @@ DatasetApiError, DatasetNotFoundError, LogfireAPIClient, + _case_detail_adapter, + _dataset_detail_adapter, + _dataset_summary_list_adapter, + _exported_dataset_adapter, _import_pydantic_evals, _serialize_case, _serialize_evaluators, @@ -57,47 +61,84 @@ class PydanticInput(BaseModel): # --- Mock transport helpers --- -FAKE_DATASET = { - 'id': 'ds-123', +FAKE_DATASET_ID = '00000000-0000-0000-0000-000000000001' +FAKE_PROJECT_ID = '00000000-0000-0000-0000-000000000002' +FAKE_CASE_ID = '00000000-0000-0000-0000-000000000003' + +# Raw JSON data as returned by the API (strings for UUIDs/datetimes). +# Has a superset of fields so it works for both DatasetSummary and DatasetDetail validation. +FAKE_DATASET_JSON: dict[str, Any] = { + 'id': FAKE_DATASET_ID, + 'project_id': FAKE_PROJECT_ID, 'name': 'test-dataset', 'description': 'A test dataset', + 'input_schema': None, + 'output_schema': None, + 'metadata_schema': None, + 'guidance': None, + 'ai_managed_guidance': False, 'case_count': 0, + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T00:00:00Z', + 'created_by': None, + 'created_by_name': None, + 'updated_by_name': None, } -FAKE_CASE = { - 'id': 'case-456', +FAKE_CASE_JSON: dict[str, Any] = { + 'id': FAKE_CASE_ID, + 'dataset_id': FAKE_DATASET_ID, 'name': 'test-case', 'inputs': {'question': 'What is 2+2?'}, 'expected_output': {'answer': '4'}, + 'metadata': None, + 'evaluators': None, + 'source_trace_id': None, + 'source_span_id': None, + 'tags': None, + 'version': 1, + 'created_at': '2024-01-01T00:00:00Z', + 'created_by': None, + 'updated_at': '2024-01-01T00:00:00Z', + 'updated_by': None, } -FAKE_EXPORT = { +FAKE_EXPORT_JSON: dict[str, Any] = { 'name': 'test-dataset', 'cases': [ { 'name': 'test-case', 'inputs': {'question': 'What is 2+2?'}, 'expected_output': {'answer': '4'}, + 'metadata': None, + 'evaluators': [], } ], + 'evaluators': [], } +# Validated versions (with UUID/datetime objects) for use in assertions. +FAKE_DATASET_SUMMARY = _dataset_summary_list_adapter.validate_python([FAKE_DATASET_JSON])[0] +FAKE_DATASET = _dataset_detail_adapter.validate_python(FAKE_DATASET_JSON) +FAKE_CASE = _case_detail_adapter.validate_python(FAKE_CASE_JSON) +FAKE_EXPORT = _exported_dataset_adapter.validate_python(FAKE_EXPORT_JSON) + def make_mock_transport(responses: dict[tuple[str, str], httpx.Response | None] | None = None) -> httpx.MockTransport: """Create a mock transport that maps (method, path) -> response.""" default_responses: dict[tuple[str, str], httpx.Response] = { - ('GET', '/v1/datasets/'): httpx.Response(200, json=[FAKE_DATASET]), - ('GET', '/v1/datasets/test-dataset/'): httpx.Response(200, json=FAKE_DATASET), - ('POST', '/v1/datasets/'): httpx.Response(200, json=FAKE_DATASET), - ('PATCH', '/v1/datasets/test-dataset/'): httpx.Response(200, json=FAKE_DATASET), + ('GET', '/v1/datasets/'): httpx.Response(200, json=[FAKE_DATASET_JSON]), + ('GET', '/v1/datasets/test-dataset/'): httpx.Response(200, json=FAKE_DATASET_JSON), + ('POST', '/v1/datasets/'): httpx.Response(200, json=FAKE_DATASET_JSON), + ('PATCH', '/v1/datasets/test-dataset/'): httpx.Response(200, json=FAKE_DATASET_JSON), ('DELETE', '/v1/datasets/test-dataset/'): httpx.Response(204), - ('GET', '/v1/datasets/test-dataset/cases/'): httpx.Response(200, json=[FAKE_CASE]), - ('GET', '/v1/datasets/test-dataset/cases/case-456/'): httpx.Response(200, json=FAKE_CASE), - ('POST', '/v1/datasets/test-dataset/cases/bulk/'): httpx.Response(200, json=[FAKE_CASE]), - ('POST', '/v1/datasets/test-dataset/import/'): httpx.Response(200, json=[FAKE_CASE]), - ('PATCH', '/v1/datasets/test-dataset/cases/case-456/'): httpx.Response(200, json=FAKE_CASE), - ('DELETE', '/v1/datasets/test-dataset/cases/case-456/'): httpx.Response(204), - ('GET', '/v1/datasets/test-dataset/export/'): httpx.Response(200, json=FAKE_EXPORT), + ('GET', '/v1/datasets/test-dataset/cases/'): httpx.Response(200, json=[FAKE_CASE_JSON]), + ('GET', '/v1/datasets/test-dataset/cases/' + FAKE_CASE_ID + '/'): httpx.Response(200, json=FAKE_CASE_JSON), + ('POST', '/v1/datasets/test-dataset/cases/bulk/'): httpx.Response(200, json=[FAKE_CASE_JSON]), + ('POST', '/v1/datasets/test-dataset/import/'): httpx.Response(200, json=[FAKE_CASE_JSON]), + ('PATCH', '/v1/datasets/test-dataset/cases/' + FAKE_CASE_ID + '/'): httpx.Response(200, json=FAKE_CASE_JSON), + ('DELETE', '/v1/datasets/test-dataset/cases/' + FAKE_CASE_ID + '/'): httpx.Response(204), + ('GET', '/v1/datasets/test-dataset/export/'): httpx.Response(200, json=FAKE_EXPORT_JSON), } if responses: @@ -456,7 +497,7 @@ def test_context_manager(self): def test_list_datasets(self): client = make_client() result = client.list_datasets() - assert result == [FAKE_DATASET] + assert result == [FAKE_DATASET_SUMMARY] def test_get_dataset(self): client = make_client() @@ -474,7 +515,7 @@ def test_create_dataset_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -511,7 +552,7 @@ def test_update_dataset_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -543,7 +584,7 @@ def test_update_dataset_clear_fields(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -570,7 +611,7 @@ def test_list_cases_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -581,7 +622,7 @@ def handler(request: httpx.Request) -> httpx.Response: def test_get_case(self): client = make_client() - result = client.get_case('test-dataset', 'case-456') + result = client.get_case('test-dataset', FAKE_CASE_ID) assert result == FAKE_CASE def test_add_cases(self): @@ -598,7 +639,7 @@ def test_add_cases_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -613,7 +654,7 @@ def handler(request: httpx.Request) -> httpx.Response: def test_update_case_minimal(self): """When no params are set, sends empty body.""" client = make_client() - result = client.update_case('test-dataset', 'case-456') + result = client.update_case('test-dataset', FAKE_CASE_ID) assert result == FAKE_CASE def test_update_case_full(self): @@ -621,7 +662,7 @@ def test_update_case_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -633,7 +674,7 @@ class MyEval: client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, name='updated', inputs=MyInput(question='new'), expected_output=MyOutput(answer='new-answer'), @@ -656,7 +697,7 @@ def test_update_case_clear_fields(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -664,7 +705,7 @@ def handler(request: httpx.Request) -> httpx.Response: client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, name=None, expected_output=None, metadata=None, @@ -684,7 +725,7 @@ def test_update_case_dict_inputs(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -692,7 +733,7 @@ def handler(request: httpx.Request) -> httpx.Response: client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, inputs={'question': 'dict-input'}, expected_output={'answer': 'dict-output'}, metadata={'source': 'dict-meta'}, @@ -705,7 +746,7 @@ def handler(request: httpx.Request) -> httpx.Response: def test_delete_case(self): client = make_client() - result = client.delete_case('test-dataset', 'case-456') + result = client.delete_case('test-dataset', FAKE_CASE_ID) assert result is None def test_export_dataset_raw(self): @@ -733,7 +774,7 @@ def test_add_cases_dicts_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -751,7 +792,7 @@ def test_add_cases_dicts_not_mutated(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = LogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -800,7 +841,7 @@ async def test_context_manager(self): async def test_list_datasets(self): client = make_async_client() result = await client.list_datasets() - assert result == [FAKE_DATASET] + assert result == [FAKE_DATASET_SUMMARY] @pytest.mark.anyio async def test_get_dataset(self): @@ -820,7 +861,7 @@ async def test_create_dataset_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -857,7 +898,7 @@ async def test_update_dataset_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -887,7 +928,7 @@ async def test_update_dataset_clear_fields(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_DATASET) + return httpx.Response(200, json=FAKE_DATASET_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -917,7 +958,7 @@ async def test_list_cases_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -929,7 +970,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_get_case(self): client = make_async_client() - result = await client.get_case('test-dataset', 'case-456') + result = await client.get_case('test-dataset', FAKE_CASE_ID) assert result == FAKE_CASE @pytest.mark.anyio @@ -945,7 +986,7 @@ async def test_add_cases_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -960,7 +1001,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_update_case_minimal(self): client = make_async_client() - result = await client.update_case('test-dataset', 'case-456') + result = await client.update_case('test-dataset', FAKE_CASE_ID) assert result == FAKE_CASE @pytest.mark.anyio @@ -969,7 +1010,7 @@ async def test_update_case_full(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -981,7 +1022,7 @@ class MyEval: await client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, name='updated', inputs=MyInput(question='new'), expected_output=MyOutput(answer='new-answer'), @@ -1003,7 +1044,7 @@ async def test_update_case_clear_fields(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -1011,7 +1052,7 @@ def handler(request: httpx.Request) -> httpx.Response: await client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, name=None, expected_output=None, metadata=None, @@ -1032,7 +1073,7 @@ async def test_update_case_dict_inputs(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=FAKE_CASE) + return httpx.Response(200, json=FAKE_CASE_JSON) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -1040,7 +1081,7 @@ def handler(request: httpx.Request) -> httpx.Response: await client.update_case( 'test-dataset', - 'case-456', + FAKE_CASE_ID, inputs={'question': 'dict-input'}, expected_output={'answer': 'dict-output'}, metadata={'source': 'dict-meta'}, @@ -1054,7 +1095,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_delete_case(self): client = make_async_client() - result = await client.delete_case('test-dataset', 'case-456') + result = await client.delete_case('test-dataset', FAKE_CASE_ID) assert result is None @pytest.mark.anyio @@ -1084,7 +1125,7 @@ async def test_add_cases_dicts_with_tags(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') @@ -1103,7 +1144,7 @@ async def test_add_cases_dicts_not_mutated(self): def handler(request: httpx.Request) -> httpx.Response: requests_seen.append(request) - return httpx.Response(200, json=[FAKE_CASE]) + return httpx.Response(200, json=[FAKE_CASE_JSON]) transport = httpx.MockTransport(handler) client = AsyncLogfireAPIClient(api_key='test-key', base_url='https://test.logfire.dev') From 4dea6abb9e464701bee8a746e79158585803a62a Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:59:59 -0700 Subject: [PATCH 2/2] Remove :meth: references from TypedDict docstrings --- logfire/experimental/api_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/logfire/experimental/api_client.py b/logfire/experimental/api_client.py index 50c393bf4..b8756be4c 100644 --- a/logfire/experimental/api_client.py +++ b/logfire/experimental/api_client.py @@ -117,8 +117,8 @@ class DatasetSummary(TypedDict): class DatasetDetail(TypedDict): """Full dataset details. - Returned by :meth:`LogfireAPIClient.get_dataset`, - :meth:`LogfireAPIClient.create_dataset`, and :meth:`LogfireAPIClient.update_dataset`. + Returned by `LogfireAPIClient.get_dataset`, + `LogfireAPIClient.create_dataset`, and `LogfireAPIClient.update_dataset`. """ id: UUID @@ -137,7 +137,7 @@ class DatasetDetail(TypedDict): class CaseDetail(TypedDict): - """Full case details, returned by case operations like :meth:`LogfireAPIClient.get_case`.""" + """Full case details, returned by case operations like `LogfireAPIClient.get_case`.""" id: UUID dataset_id: UUID @@ -157,7 +157,7 @@ class CaseDetail(TypedDict): class CaseData(TypedDict): - """Data for creating a case via :meth:`LogfireAPIClient.add_cases`. + """Data for creating a case via `LogfireAPIClient.add_cases`. Only ``inputs`` is required; all other fields are optional. """ @@ -183,7 +183,7 @@ class ExportedCase(TypedDict): class ExportedDataset(TypedDict): """Dataset export in pydantic-evals compatible format. - Returned by :meth:`LogfireAPIClient.export_dataset` when called without type arguments. + Returned by `LogfireAPIClient.export_dataset` when called without type arguments. Compatible with ``pydantic_evals.Dataset.from_dict()``. """