diff --git a/pyproject.toml b/pyproject.toml index 44817bb..021f3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,10 @@ dev = [ "uipath-llm-client[all]", "uipath_langchain_client[all]", "openinference-instrumentation-langchain>=0.1.63,<1.0.0", + "python-docx>=1.1.2,<2.0.0", + "openpyxl>=3.1.5,<4.0.0", + "reportlab>=4.2.5,<5.0.0", + "Pillow>=12.0.0,<13.0.0", ] [tool.uv] diff --git a/tests/cassettes.db b/tests/cassettes.db index bbd58c8..15c2205 100644 Binary files a/tests/cassettes.db and b/tests/cassettes.db differ diff --git a/tests/langchain/clients/anthropic/test_integration.py b/tests/langchain/clients/anthropic/test_integration.py index f2c133c..661b370 100644 --- a/tests/langchain/clients/anthropic/test_integration.py +++ b/tests/langchain/clients/anthropic/test_integration.py @@ -3,58 +3,33 @@ Tests UiPathChatAnthropic with both vertexai and awsbedrock vendor_types. """ +from collections.abc import Iterable from typing import Any import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests +from langchain_core.runnables import Runnable -from tests.langchain.utils import search_accommodation, search_attractions, search_flights +from tests.langchain.file_fixtures import IMAGE_FORMATS, PDF_FORMATS +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests @pytest.mark.asyncio @pytest.mark.vcr -class TestAnthropicIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestAnthropicIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, request: pytest.FixtureRequest, completions_config: tuple[type[BaseChatModel], dict[str, Any]], ) -> None: - model_class, model_kwargs = completions_config + _, model_kwargs = completions_config model_name = model_kwargs.get("model", "") test_name = request.node.originalname has_thinking = "thinking" in model_kwargs is_vertex = model_kwargs.get("vendor_type") == "vertexai" or "@" in model_name - - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") + callspec = getattr(request.node, "callspec", None) + fmt = callspec.params.get("fmt") if callspec else None # Claude via Vertex AI: streaming bugged (502 / empty content) if is_vertex and test_name in [ @@ -111,102 +86,29 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: URL image sources not supported via gateway") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None: - """Test parallel tool calling for Claude models.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", - ) - model_with_tools_parallel = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore + # File-input matrix: structured output forces tool_choice (incompatible with + # thinking); image/PDF blocks don't round-trip through the Anthropic gateway. + if test_name in ("test_file_inputs", "test_file_inputs_async"): + if has_thinking: + pytest.skip( + "Structured output forces tool_choice, which is incompatible with thinking" + ) + if fmt in (IMAGE_FORMATS | PDF_FORMATS): + pytest.skip( + "Image/PDF content blocks are not supported via the Anthropic gateway path" + ) + + def _bind_parallel_and_sequential( + self, model: BaseChatModel, tools: Iterable[Any] + ) -> tuple[Runnable, Runnable]: + tools_list = list(tools) + return ( + model.bind_tools( + tools_list, + tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore + ), + model.bind_tools( + tools_list, + tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore + ), ) - model_with_tools_sequential = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore - ) - - parallel_response = model_with_tools_parallel.invoke(prompt) - sequential_response = model_with_tools_sequential.invoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools) - assert len(sequential_response.tool_calls) == 1 - - async def test_parallel_and_sequential_tool_calling_async(self, model: BaseChatModel) -> None: - """Test parallel and sequential tool calling async for Claude.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", - ) - model_with_tools_parallel = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore - ) - model_with_tools_sequential = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore - ) - - parallel_response = await model_with_tools_parallel.ainvoke(prompt) - sequential_response = await model_with_tools_sequential.ainvoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools) - assert len(sequential_response.tool_calls) == 1 diff --git a/tests/langchain/clients/bedrock/test_integration.py b/tests/langchain/clients/bedrock/test_integration.py index 15b6bd6..6b56dd1 100644 --- a/tests/langchain/clients/bedrock/test_integration.py +++ b/tests/langchain/clients/bedrock/test_integration.py @@ -1,47 +1,23 @@ """LangChain integration tests for Bedrock provider clients.""" +from collections.abc import Iterable from typing import Any import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests +from langchain_core.runnables import Runnable from uipath_langchain_client.clients.bedrock.chat_models import ( UiPathChatAnthropicBedrock, UiPathChatBedrock, UiPathChatBedrockConverse, ) -from tests.langchain.utils import search_accommodation, search_attractions, search_flights +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests @pytest.mark.asyncio @pytest.mark.vcr -class TestBedrockIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestBedrockIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, @@ -52,10 +28,6 @@ def skip_on_specific_configs( test_name = request.node.originalname has_thinking = "thinking" in model_kwargs - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") - # Claude + thinking: tool_choice forces tool use, incompatible with thinking if has_thinking and test_name in [ "test_structured_few_shot_examples", @@ -113,119 +85,27 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: serialization not supported on Bedrock Converse") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class + # File-input matrix: structured output uses tool_choice; thinking is incompatible. + if test_name in ("test_file_inputs", "test_file_inputs_async") and has_thinking: + pytest.skip("Structured output forces tool_choice, which is incompatible with thinking") - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None: - """Test parallel tool calling for Claude Bedrock models.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", - ) + def _bind_parallel_and_sequential( + self, model: BaseChatModel, tools: Iterable[Any] + ) -> tuple[Runnable, Runnable]: + tools_list = list(tools) if isinstance(model, UiPathChatAnthropicBedrock): - # UiPathChatAnthropicBedrock uses parallel_tool_calls as a top-level param - model_with_tools_parallel = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=True + # UiPathChatAnthropicBedrock uses parallel_tool_calls as a top-level param. + return ( + model.bind_tools(tools_list, tool_choice="any", parallel_tool_calls=True), + model.bind_tools(tools_list, tool_choice="any", parallel_tool_calls=False), ) - model_with_tools_sequential = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=False - ) - else: - model_with_tools_parallel = model.bind_tools( - tools, + return ( + model.bind_tools( + tools_list, tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore - ) - model_with_tools_sequential = model.bind_tools( - tools, + ), + model.bind_tools( + tools_list, tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore - ) - - parallel_response = model_with_tools_parallel.invoke(prompt) - sequential_response = model_with_tools_sequential.invoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools) - assert len(sequential_response.tool_calls) == 1 - - async def test_parallel_and_sequential_tool_calling_async(self, model: BaseChatModel) -> None: - """Test parallel and sequential tool calling async for Bedrock.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", + ), ) - if isinstance(model, UiPathChatAnthropicBedrock): - model_with_tools_parallel = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=True - ) - model_with_tools_sequential = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=False - ) - else: - model_with_tools_parallel = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore - ) - model_with_tools_sequential = model.bind_tools( - tools, - tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore - ) - - parallel_response = await model_with_tools_parallel.ainvoke(prompt) - sequential_response = await model_with_tools_sequential.ainvoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools) - assert len(sequential_response.tool_calls) == 1 diff --git a/tests/langchain/clients/google/test_integration.py b/tests/langchain/clients/google/test_integration.py index 02067e3..2d61584 100644 --- a/tests/langchain/clients/google/test_integration.py +++ b/tests/langchain/clients/google/test_integration.py @@ -5,54 +5,26 @@ import pytest from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests, EmbeddingsIntegrationTests +from langchain_tests.integration_tests import EmbeddingsIntegrationTests +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests from uipath.llm_client.settings import PlatformSettings @pytest.mark.asyncio @pytest.mark.vcr -class TestGoogleIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestGoogleIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, request: pytest.FixtureRequest, completions_config: tuple[type[BaseChatModel], dict[str, Any]], ) -> None: - model_class, model_kwargs = completions_config + _, model_kwargs = completions_config model_name = model_kwargs.get("model", "") test_name = request.node.originalname is_gemini_3 = "gemini-3" in model_name.lower() - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") - # Gemini GoogleGenerativeAI: tool_message_histories / tool_message_error_status if is_gemini_3 and test_name in [ "test_tool_message_histories_string_content", @@ -75,56 +47,6 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: not supported for Gemini models") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - @pytest.mark.asyncio @pytest.mark.vcr diff --git a/tests/langchain/clients/litellm/test_integration.py b/tests/langchain/clients/litellm/test_integration.py index 2b920ec..64dc87f 100644 --- a/tests/langchain/clients/litellm/test_integration.py +++ b/tests/langchain/clients/litellm/test_integration.py @@ -8,39 +8,25 @@ import pytest from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel -from langchain_tests.integration_tests import ChatModelIntegrationTests, EmbeddingsIntegrationTests +from langchain_tests.integration_tests import EmbeddingsIntegrationTests + +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests @pytest.mark.asyncio @pytest.mark.vcr -class TestLiteLLMIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - +class TestLiteLLMIntegrationChatModel(UiPathChatModelIntegrationTests): @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True + def supports_pdf_inputs(self) -> bool: + return False @property - def supports_image_urls(self) -> bool: - return True + def supports_pdf_tool_message(self) -> bool: + return False @property - def supports_pdf_inputs(self) -> bool: - return False + def tool_choice_value(self) -> str: + return "required" @pytest.fixture(autouse=True) def skip_on_specific_configs( @@ -56,10 +42,6 @@ def skip_on_specific_configs( is_bedrock = model_name.startswith("anthropic.") is_vertex_claude = "@" in model_name and is_claude - # Skip framework-internal tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") - # Streaming tests — Bedrock invoke streaming returns 500 from gateway # ("Unable to extract claim sub_type from token") if is_bedrock and test_name in [ @@ -125,9 +107,22 @@ def skip_on_specific_configs( "ls_structured_output_format (upstream langchain-litellm issue)" ) - @property - def tool_choice_value(self) -> str: - return "required" + # Parallel tool calling has historically not been exercised on the litellm + # client (the previous class didn't override the test); leave the assertion + # off until it has dedicated coverage. + if test_name in ( + "test_parallel_and_sequential_tool_calling", + "test_parallel_and_sequential_tool_calling_async", + ): + pytest.skip(f"Skipping {test_name}: not yet exercised on the LiteLLM client") + + # File-input matrix: reuses with_structured_output, which is unreliable on + # ChatLiteLLM for the same reason `test_structured_output` is skipped. + if test_name in ("test_file_inputs", "test_file_inputs_async"): + pytest.skip( + "Structured output via ChatLiteLLM is not currently exercised " + "(upstream langchain-litellm issue)" + ) @pytest.mark.asyncio diff --git a/tests/langchain/clients/normalized/test_integration.py b/tests/langchain/clients/normalized/test_integration.py index 4152c6f..b7992e4 100644 --- a/tests/langchain/clients/normalized/test_integration.py +++ b/tests/langchain/clients/normalized/test_integration.py @@ -9,47 +9,24 @@ import pytest from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests, EmbeddingsIntegrationTests +from langchain_tests.integration_tests import EmbeddingsIntegrationTests from uipath_langchain_client.clients.normalized.embeddings import UiPathEmbeddings +from tests.langchain.file_fixtures import IMAGE_FORMATS, PDF_FORMATS +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests from uipath.llm_client.settings import PlatformSettings @pytest.mark.asyncio @pytest.mark.vcr -class TestNormalizedIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestNormalizedIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, request: pytest.FixtureRequest, completions_config: tuple[type[BaseChatModel], dict[str, Any]], ) -> None: - model_class, model_kwargs = completions_config + _, model_kwargs = completions_config model_name = model_kwargs.get("model", "") test_name = request.node.originalname has_thinking = "thinking" in model_kwargs @@ -57,10 +34,9 @@ def skip_on_specific_configs( is_gemini = "gemini" in model_name.lower() is_gemini_3 = "gemini-3" in model_name.lower() is_vertex_claude = "@" in model_name and is_claude - - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") + is_bedrock_claude = "anthropic." in model_name.lower() + callspec = getattr(request.node, "callspec", None) + fmt = callspec.params.get("fmt") if callspec else None # Claude via Vertex AI: streaming bugged (502 / empty content) if is_vertex_claude and test_name in [ @@ -149,7 +125,7 @@ def skip_on_specific_configs( pytest.skip(f"Skipping {test_name}: not supported for Claude via Vertex on normalized") # UiPathChat (normalized) + Claude via Bedrock: image/pdf/parallel - if "anthropic." in model_name.lower() and test_name in [ + if is_bedrock_claude and test_name in [ "test_image_inputs", "test_pdf_inputs", "test_parallel_and_sequential_tool_calling", @@ -171,55 +147,21 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: not supported for GPT models") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) + # File-input matrix skips on the normalized API + if test_name in ("test_file_inputs", "test_file_inputs_async"): + if has_thinking: + pytest.skip( + "Structured output forces tool_choice, incompatible with Claude thinking" + ) + if "gpt" in model_name.lower() and fmt in PDF_FORMATS: + pytest.skip("PDF inputs not supported for GPT on the normalized API") + if is_gemini and fmt in PDF_FORMATS: + pytest.skip("PDF inputs not supported for Gemini on the normalized API") + if (is_vertex_claude or is_bedrock_claude) and fmt in (IMAGE_FORMATS | PDF_FORMATS): + pytest.skip( + "Image/PDF content blocks not supported for Claude via " + "Vertex/Bedrock on the normalized API" + ) def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None: """Test parallel tool calling - normalized API delegates to provider.""" diff --git a/tests/langchain/clients/openai/test_integration.py b/tests/langchain/clients/openai/test_integration.py index 5b75de8..be9d23f 100644 --- a/tests/langchain/clients/openai/test_integration.py +++ b/tests/langchain/clients/openai/test_integration.py @@ -5,52 +5,26 @@ import pytest from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests, EmbeddingsIntegrationTests +from langchain_tests.integration_tests import EmbeddingsIntegrationTests -from tests.langchain.utils import search_accommodation, search_attractions, search_flights +from tests.langchain.file_fixtures import PDF_FORMATS +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests @pytest.mark.asyncio @pytest.mark.vcr -class TestOpenAIIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestOpenAIIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, request: pytest.FixtureRequest, completions_config: tuple[type[BaseChatModel], dict[str, Any]], ) -> None: - model_class, model_kwargs = completions_config + _, model_kwargs = completions_config model_name = model_kwargs.get("model", "") test_name = request.node.originalname - - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") + callspec = getattr(request.node, "callspec", None) + fmt = callspec.params.get("fmt") if callspec else None # GPT-5 / responses_api: stop_sequence not supported if ("gpt-5" in model_name.lower() or "use_responses_api" in model_kwargs) and test_name in [ @@ -66,109 +40,13 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: not supported for GPT models") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None: - """Test parallel tool calling - model should call multiple tools at once.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", - ) - model_with_tools_parallel = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=True - ) - model_with_tools_sequential = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=False - ) - - parallel_response = model_with_tools_parallel.invoke(prompt) - sequential_response = model_with_tools_sequential.invoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools), ( - f"Expected multiple different tools to be called in parallel, got: {parallel_response.tool_calls}" - ) - assert len(sequential_response.tool_calls) == 1, ( - f"Expected only one tool to be called in sequential mode, got: {sequential_response.tool_calls}" - ) - - async def test_parallel_and_sequential_tool_calling_async(self, model: BaseChatModel) -> None: - """Test parallel and sequential tool calling async.""" - tools = [search_accommodation, search_flights, search_attractions] - prompt = ( - "I want to plan a trip to Paris from New York. " - "I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.", - "Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.", - ) - model_with_tools_parallel = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=True - ) - model_with_tools_sequential = model.bind_tools( - tools, tool_choice="any", parallel_tool_calls=False - ) - - parallel_response = await model_with_tools_parallel.ainvoke(prompt) - sequential_response = await model_with_tools_sequential.ainvoke(prompt) - - assert parallel_response.tool_calls is not None - assert sequential_response.tool_calls is not None - assert len(parallel_response.tool_calls) == len(tools), ( - f"Expected multiple different tools to be called in parallel, got: {parallel_response.tool_calls}" - ) - assert len(sequential_response.tool_calls) == 1, ( - f"Expected only one tool to be called in sequential mode, got: {sequential_response.tool_calls}" - ) + # File-input matrix: PDF file blocks require the Responses API on Azure OpenAI. + if ( + test_name in ("test_file_inputs", "test_file_inputs_async") + and fmt in PDF_FORMATS + and not model_kwargs.get("use_responses_api") + ): + pytest.skip("PDF inputs require the OpenAI Responses API") @pytest.mark.asyncio diff --git a/tests/langchain/clients/vertexai/test_integration.py b/tests/langchain/clients/vertexai/test_integration.py index 91a95ef..fb8c55c 100644 --- a/tests/langchain/clients/vertexai/test_integration.py +++ b/tests/langchain/clients/vertexai/test_integration.py @@ -1,54 +1,28 @@ """LangChain integration tests for VertexAI provider client.""" +from collections.abc import Iterable from typing import Any import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessageChunk -from langchain_tests.integration_tests import ChatModelIntegrationTests +from langchain_core.runnables import Runnable + +from tests.langchain.integration_tests import UiPathChatModelIntegrationTests @pytest.mark.asyncio @pytest.mark.vcr -class TestVertexAIIntegrationChatModel(ChatModelIntegrationTests): - @pytest.fixture(autouse=True) - def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]): - self._completions_class, self.completions_kwargs = completions_config - - @property - def supports_image_inputs(self) -> bool: - return True - - @property - def supports_image_tool_message(self) -> bool: - return True - - @property - def supports_image_urls(self) -> bool: - return True - - @property - def supports_pdf_inputs(self) -> bool: - return True - - @property - def supports_pdf_tool_message(self) -> bool: - return True - +class TestVertexAIIntegrationChatModel(UiPathChatModelIntegrationTests): @pytest.fixture(autouse=True) def skip_on_specific_configs( self, request: pytest.FixtureRequest, completions_config: tuple[type[BaseChatModel], dict[str, Any]], ) -> None: - model_class, model_kwargs = completions_config + _, model_kwargs = completions_config test_name = request.node.originalname has_thinking = "thinking" in model_kwargs - # Useless framework tests - if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]: - pytest.skip(f"Skipping {test_name}: not relevant") - # Claude + thinking: tool_choice forces tool use, incompatible with thinking if has_thinking and test_name in [ "test_structured_few_shot_examples", @@ -135,52 +109,22 @@ def skip_on_specific_configs( ]: pytest.skip(f"Skipping {test_name}: not supported on this client") - @property - def chat_model_class(self) -> type[BaseChatModel]: - return self._completions_class - - @property - def chat_model_params(self) -> dict[str, Any]: - return self.completions_kwargs - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - def test_stream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - for chunk in model.stream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" - ) - - @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) - async def test_astream(self, model: BaseChatModel) -> None: - chunks: list[AIMessageChunk] = [] - full: AIMessageChunk | None = None - async for chunk in model.astream("Hello"): - assert chunk is not None - assert isinstance(chunk, AIMessageChunk) - assert isinstance(chunk.content, str | list) - chunks.append(chunk) - full = chunk if full is None else full + chunk - assert len(chunks) > 0 - assert isinstance(full, AIMessageChunk) - assert full.content - text_blocks = [block for block in full.content_blocks if block["type"] == "text"] - assert len(text_blocks) == 1 - - last_chunk = chunks[-1] - assert last_chunk.chunk_position == "last", ( - f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" + # File-input matrix: structured output not supported on this client (matches + # the test_structured_output skip above). + if test_name in ("test_file_inputs", "test_file_inputs_async"): + pytest.skip("Structured output is not currently supported on this client") + + def _bind_parallel_and_sequential( + self, model: BaseChatModel, tools: Iterable[Any] + ) -> tuple[Runnable, Runnable]: + tools_list = list(tools) + return ( + model.bind_tools( + tools_list, + tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore + ), + model.bind_tools( + tools_list, + tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore + ), ) diff --git a/tests/langchain/file_fixtures.py b/tests/langchain/file_fixtures.py new file mode 100644 index 0000000..fdb32b5 --- /dev/null +++ b/tests/langchain/file_fixtures.py @@ -0,0 +1,278 @@ +"""Dummy file fixtures for LangChain file-input integration tests. + +Each generator produces a file containing the same known invoice payload (see +``INVOICE_TEXT``). The generated files are checked into +``tests/langchain/fixtures/files/`` so test runs are deterministic and don't +re-render binaries on every invocation. The generators are kept so we can +regenerate the bundle when adding a new format or tweaking content — run:: + + python -m tests.langchain.file_fixtures + +Tests load files via ``load_fixture(fmt)`` and convert them into a LangChain +``HumanMessage`` content list via ``build_human_content_block(fmt, data)``, +which assertion code then sends through ``model.with_structured_output``. +""" + +from __future__ import annotations + +import base64 +import csv +import io +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + +INVOICE_NUMBER = "INV-7421" +CUSTOMER = "Acme Corp" +# Whole-dollar amount on purpose: when rendered into an image, OCR sometimes +# drops the decimal point of values like "$1234.56" and the model then returns +# 123456.0 instead of 1234.56 (observed on Bedrock-hosted Claude and flaky +# Gemini thinking runs). An integer total sidesteps the ambiguity. +TOTAL_AMOUNT = 4200 +DUE_DATE = "2026-03-15" + +INVOICE_TEXT = ( + f"Invoice Number: {INVOICE_NUMBER}\n" + f"Customer: {CUSTOMER}\n" + f"Total Amount: ${TOTAL_AMOUNT}\n" + f"Due Date: {DUE_DATE}\n" +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" / "files" + + +class InvoiceInfo(BaseModel): + """Structured output schema returned by the model under test.""" + + invoice_number: str = Field(description="The invoice number, e.g. INV-1234") + customer: str = Field(description="The customer name on the invoice") + total_amount: float = Field(description="The total amount in USD as a number") + + +def make_txt() -> bytes: + return INVOICE_TEXT.encode("utf-8") + + +def make_md() -> bytes: + body = ( + "# Invoice\n\n" + f"- **Invoice Number:** {INVOICE_NUMBER}\n" + f"- **Customer:** {CUSTOMER}\n" + f"- **Total Amount:** ${TOTAL_AMOUNT}\n" + f"- **Due Date:** {DUE_DATE}\n" + ) + return body.encode("utf-8") + + +def make_csv() -> bytes: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["field", "value"]) + writer.writerow(["invoice_number", INVOICE_NUMBER]) + writer.writerow(["customer", CUSTOMER]) + writer.writerow(["total_amount", str(TOTAL_AMOUNT)]) + writer.writerow(["due_date", DUE_DATE]) + return buf.getvalue().encode("utf-8") + + +def make_html() -> bytes: + body = ( + "" + "

Invoice

" + f"

Invoice Number: {INVOICE_NUMBER}

" + f"

Customer: {CUSTOMER}

" + f"

Total Amount: ${TOTAL_AMOUNT}

" + f"

Due Date: {DUE_DATE}

" + "" + ) + return body.encode("utf-8") + + +def make_pdf() -> bytes: + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf) + c.setFont("Helvetica", 14) + y = 800 + for line in INVOICE_TEXT.splitlines(): + c.drawString(72, y, line) + y -= 24 + c.save() + return buf.getvalue() + + +def make_docx() -> bytes: + from docx import Document + + doc = Document() + doc.add_heading("Invoice", level=1) + for line in INVOICE_TEXT.splitlines(): + doc.add_paragraph(line) + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +def make_xlsx() -> bytes: + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + assert ws is not None + ws.title = "Invoice" + ws.append(["field", "value"]) + ws.append(["invoice_number", INVOICE_NUMBER]) + ws.append(["customer", CUSTOMER]) + ws.append(["total_amount", TOTAL_AMOUNT]) + ws.append(["due_date", DUE_DATE]) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _make_image(fmt: str) -> bytes: + """Render the invoice text into an image so the model has to OCR it.""" + from PIL import Image, ImageDraw, ImageFont + + width, height = 640, 360 + img = Image.new("RGB", (width, height), color="white") + draw = ImageDraw.Draw(img) + try: + font = ImageFont.truetype("DejaVuSans.ttf", 22) + except OSError: + font = ImageFont.load_default() + y = 40 + for line in INVOICE_TEXT.splitlines(): + draw.text((40, y), line, fill="black", font=font) + y += 40 + buf = io.BytesIO() + save_kwargs: dict[str, Any] = {"format": fmt.upper()} + if fmt.lower() in ("jpg", "jpeg"): + save_kwargs["format"] = "JPEG" + save_kwargs["quality"] = 95 + img.save(buf, **save_kwargs) + return buf.getvalue() + + +def make_png() -> bytes: + return _make_image("PNG") + + +def make_jpg() -> bytes: + return _make_image("JPEG") + + +def make_gif() -> bytes: + return _make_image("GIF") + + +def make_webp() -> bytes: + return _make_image("WEBP") + + +GENERATORS = { + "txt": (make_txt, "text/plain"), + "md": (make_md, "text/markdown"), + "csv": (make_csv, "text/csv"), + "html": (make_html, "text/html"), + "pdf": (make_pdf, "application/pdf"), + "docx": (make_docx, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + "xlsx": (make_xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + "png": (make_png, "image/png"), + "jpg": (make_jpg, "image/jpeg"), + "gif": (make_gif, "image/gif"), + "webp": (make_webp, "image/webp"), +} + +TEXT_LIKE_FORMATS = {"txt", "md", "csv", "html"} +IMAGE_FORMATS = {"png", "jpg", "gif", "webp"} +PDF_FORMATS = {"pdf"} +OFFICE_FORMATS = {"docx", "xlsx"} + + +def fixture_path(fmt: str) -> Path: + return FIXTURES_DIR / f"invoice.{fmt}" + + +def load_fixture(fmt: str) -> bytes: + """Read the committed fixture for ``fmt`` from ``tests/langchain/fixtures/files``.""" + return fixture_path(fmt).read_bytes() + + +def regenerate_fixtures() -> None: + """Re-generate every committed fixture file. Run via ``python -m tests.langchain.file_fixtures``.""" + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + for fmt, (generator, _) in GENERATORS.items(): + fixture_path(fmt).write_bytes(generator()) + + +def extract_text_for_office(fmt: str, data: bytes) -> str: + """Return readable text extracted from a docx/xlsx blob. + + Most LLM providers don't accept raw Office binaries as content blocks, so + we pre-extract on the client side and send the result as plain text. + """ + buf = io.BytesIO(data) + if fmt == "docx": + from docx import Document + + doc = Document(buf) + return "\n".join(p.text for p in doc.paragraphs if p.text) + if fmt == "xlsx": + from openpyxl import load_workbook + + wb = load_workbook(buf, read_only=True, data_only=True) + lines: list[str] = [] + for ws in wb.worksheets: + for row in ws.iter_rows(values_only=True): + lines.append(",".join("" if v is None else str(v) for v in row)) + return "\n".join(lines) + raise ValueError(f"extract_text_for_office does not handle {fmt!r}") + + +def build_human_content_block(fmt: str, data: bytes) -> list[dict[str, Any]]: + """Return the LangChain HumanMessage content blocks for a generated file. + + - text-like formats are sent inline as a fenced code block in a text block + - images go through the standard ``image`` block (base64) + - pdf goes through the standard ``file`` block (base64) + - docx/xlsx are pre-extracted to text and sent as a text block + """ + prompt = "Extract the invoice number, customer, and total amount from the attached file." + if fmt in TEXT_LIKE_FORMATS: + text = data.decode("utf-8") + return [ + {"type": "text", "text": f"{prompt}\n\n```{fmt}\n{text}\n```"}, + ] + if fmt in OFFICE_FORMATS: + text = extract_text_for_office(fmt, data) + return [ + {"type": "text", "text": f"{prompt}\n\n```{fmt}\n{text}\n```"}, + ] + if fmt in PDF_FORMATS: + return [ + {"type": "text", "text": prompt}, + { + "type": "file", + "base64": base64.b64encode(data).decode("ascii"), + "mime_type": "application/pdf", + }, + ] + if fmt in IMAGE_FORMATS: + mime = GENERATORS[fmt][1] + return [ + {"type": "text", "text": prompt}, + { + "type": "image", + "base64": base64.b64encode(data).decode("ascii"), + "mime_type": mime, + }, + ] + raise ValueError(f"Unsupported fixture format: {fmt!r}") + + +if __name__ == "__main__": + regenerate_fixtures() + print(f"Wrote {len(GENERATORS)} fixtures to {FIXTURES_DIR}") diff --git a/tests/langchain/fixtures/files/invoice.csv b/tests/langchain/fixtures/files/invoice.csv new file mode 100644 index 0000000..0c44e3a --- /dev/null +++ b/tests/langchain/fixtures/files/invoice.csv @@ -0,0 +1,5 @@ +field,value +invoice_number,INV-7421 +customer,Acme Corp +total_amount,4200 +due_date,2026-03-15 diff --git a/tests/langchain/fixtures/files/invoice.docx b/tests/langchain/fixtures/files/invoice.docx new file mode 100644 index 0000000..d8b4c64 Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.docx differ diff --git a/tests/langchain/fixtures/files/invoice.gif b/tests/langchain/fixtures/files/invoice.gif new file mode 100644 index 0000000..6c169a3 Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.gif differ diff --git a/tests/langchain/fixtures/files/invoice.html b/tests/langchain/fixtures/files/invoice.html new file mode 100644 index 0000000..dc4ee7f --- /dev/null +++ b/tests/langchain/fixtures/files/invoice.html @@ -0,0 +1 @@ +

Invoice

Invoice Number: INV-7421

Customer: Acme Corp

Total Amount: $4200

Due Date: 2026-03-15

\ No newline at end of file diff --git a/tests/langchain/fixtures/files/invoice.jpg b/tests/langchain/fixtures/files/invoice.jpg new file mode 100644 index 0000000..d7de9e1 Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.jpg differ diff --git a/tests/langchain/fixtures/files/invoice.md b/tests/langchain/fixtures/files/invoice.md new file mode 100644 index 0000000..c0ee17b --- /dev/null +++ b/tests/langchain/fixtures/files/invoice.md @@ -0,0 +1,6 @@ +# Invoice + +- **Invoice Number:** INV-7421 +- **Customer:** Acme Corp +- **Total Amount:** $4200 +- **Due Date:** 2026-03-15 diff --git a/tests/langchain/fixtures/files/invoice.pdf b/tests/langchain/fixtures/files/invoice.pdf new file mode 100644 index 0000000..c709eed Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.pdf differ diff --git a/tests/langchain/fixtures/files/invoice.png b/tests/langchain/fixtures/files/invoice.png new file mode 100644 index 0000000..ea8d9a8 Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.png differ diff --git a/tests/langchain/fixtures/files/invoice.txt b/tests/langchain/fixtures/files/invoice.txt new file mode 100644 index 0000000..3b6da86 --- /dev/null +++ b/tests/langchain/fixtures/files/invoice.txt @@ -0,0 +1,4 @@ +Invoice Number: INV-7421 +Customer: Acme Corp +Total Amount: $4200 +Due Date: 2026-03-15 diff --git a/tests/langchain/fixtures/files/invoice.webp b/tests/langchain/fixtures/files/invoice.webp new file mode 100644 index 0000000..d3af360 Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.webp differ diff --git a/tests/langchain/fixtures/files/invoice.xlsx b/tests/langchain/fixtures/files/invoice.xlsx new file mode 100644 index 0000000..8f1f21e Binary files /dev/null and b/tests/langchain/fixtures/files/invoice.xlsx differ diff --git a/tests/langchain/integration_tests.py b/tests/langchain/integration_tests.py new file mode 100644 index 0000000..6398dba --- /dev/null +++ b/tests/langchain/integration_tests.py @@ -0,0 +1,228 @@ +"""Shared base class for the per-provider LangChain integration tests. + +`UiPathChatModelIntegrationTests` consolidates the overrides that every +provider's `ChatModelIntegrationTests` subclass previously duplicated: + +- `setup_models` autouse fixture (reads the provider-local `completions_config`) +- `chat_model_class` / `chat_model_params` properties +- `supports_*` property defaults (all `True`; override in a subclass to disable) +- The `test_stream` / `test_astream` overrides that parametrize on + ``output_version`` and assert `chunk_position == "last"` +- The `test_parallel_and_sequential_tool_calling[_async]` methods, with a + ``_bind_parallel_and_sequential`` hook that providers override to switch + between the OpenAI dialect (``parallel_tool_calls``) and the Anthropic + dialect (``disable_parallel_tool_use``) +- The new `test_file_inputs[_async]` matrix, parameterized over every format in + `tests.langchain.file_fixtures.GENERATORS` (txt/md/csv/html, pdf, docx/xlsx, + png/jpg/gif/webp). Each test asks the model for structured output and + asserts the embedded invoice payload round-trips. + +Subclasses should still declare a `skip_on_specific_configs` fixture for +provider-specific skips, and override `supports_*` properties or +`_bind_parallel_and_sequential` where the defaults don't apply. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, cast + +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessageChunk, HumanMessage +from langchain_core.runnables import Runnable +from langchain_tests.integration_tests import ChatModelIntegrationTests + +from tests.langchain.file_fixtures import ( + CUSTOMER, + GENERATORS, + INVOICE_NUMBER, + TOTAL_AMOUNT, + InvoiceInfo, + build_human_content_block, + load_fixture, +) +from tests.langchain.utils import search_accommodation, search_attractions, search_flights + +_PARALLEL_TOOL_PROMPT = ( + "I want to plan a trip to Paris from New York. " + "I need to find flights for March 15th, accommodation from March 15th to March 20th, " + "and things to do there.", + "Search for accomodations, flights and attractions in parallel. " + "Don't repeat the same tool call.", +) + +_FILE_INPUT_FORMATS = sorted(GENERATORS.keys()) + + +def _assert_invoice(result: Any) -> None: + assert isinstance(result, InvoiceInfo), f"Unexpected result type: {type(result)!r}" + assert result.invoice_number.upper().replace(" ", "") == INVOICE_NUMBER, ( + f"Got invoice_number={result.invoice_number!r}, expected {INVOICE_NUMBER!r}" + ) + assert CUSTOMER.lower() in result.customer.lower(), ( + f"Got customer={result.customer!r}, expected to contain {CUSTOMER!r}" + ) + assert abs(result.total_amount - TOTAL_AMOUNT) < 0.01, ( + f"Got total_amount={result.total_amount!r}, expected ≈ {TOTAL_AMOUNT}" + ) + + +class UiPathChatModelIntegrationTests(ChatModelIntegrationTests): + @pytest.fixture(autouse=True) + def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]) -> None: + self._completions_class, self.completions_kwargs = completions_config + + @pytest.fixture(autouse=True) + def skip_framework_irrelevant(self, request: pytest.FixtureRequest) -> None: + if request.node.originalname in ( + "test_no_overrides_DO_NOT_OVERRIDE", + "test_unicode_tool_call_integration", + ): + pytest.skip(f"Skipping {request.node.originalname}: not relevant") + + @property + def chat_model_class(self) -> type[BaseChatModel]: + return self._completions_class + + @property + def chat_model_params(self) -> dict[str, Any]: + return self.completions_kwargs + + @property + def supports_image_inputs(self) -> bool: + return True + + @property + def supports_image_tool_message(self) -> bool: + return True + + @property + def supports_image_urls(self) -> bool: + return True + + @property + def supports_pdf_inputs(self) -> bool: + return True + + @property + def supports_pdf_tool_message(self) -> bool: + return True + + @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) + def test_stream(self, model: BaseChatModel) -> None: + chunks: list[AIMessageChunk] = [] + full: AIMessageChunk | None = None + for chunk in model.stream("Hello"): + assert chunk is not None + assert isinstance(chunk, AIMessageChunk) + assert isinstance(chunk.content, str | list) + chunks.append(chunk) + full = chunk if full is None else full + chunk + assert len(chunks) > 0 + assert isinstance(full, AIMessageChunk) + assert full.content + text_blocks = [block for block in full.content_blocks if block["type"] == "text"] + assert len(text_blocks) == 1 + + last_chunk = chunks[-1] + assert last_chunk.chunk_position == "last", ( + f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" + ) + + @pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True) + async def test_astream(self, model: BaseChatModel) -> None: + chunks: list[AIMessageChunk] = [] + full: AIMessageChunk | None = None + async for chunk in model.astream("Hello"): + assert chunk is not None + assert isinstance(chunk, AIMessageChunk) + assert isinstance(chunk.content, str | list) + chunks.append(chunk) + full = chunk if full is None else full + chunk + assert len(chunks) > 0 + assert isinstance(full, AIMessageChunk) + assert full.content + text_blocks = [block for block in full.content_blocks if block["type"] == "text"] + assert len(text_blocks) == 1 + + last_chunk = chunks[-1] + assert last_chunk.chunk_position == "last", ( + f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}" + ) + + def _bind_parallel_and_sequential( + self, model: BaseChatModel, tools: Iterable[Any] + ) -> tuple[Runnable, Runnable]: + """Return ``(parallel_model, sequential_model)`` for the parallel test. + + Default is the OpenAI dialect (``parallel_tool_calls`` kwarg). Providers + on the Anthropic dialect override to use ``disable_parallel_tool_use`` + inside ``tool_choice``. + """ + tools_list = list(tools) + return ( + model.bind_tools(tools_list, tool_choice="any", parallel_tool_calls=True), + model.bind_tools(tools_list, tool_choice="any", parallel_tool_calls=False), + ) + + def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None: + tools = [search_accommodation, search_flights, search_attractions] + parallel, sequential = self._bind_parallel_and_sequential(model, tools) + + parallel_response = parallel.invoke(_PARALLEL_TOOL_PROMPT) + sequential_response = sequential.invoke(_PARALLEL_TOOL_PROMPT) + + assert parallel_response.tool_calls is not None + assert sequential_response.tool_calls is not None + assert len(parallel_response.tool_calls) == len(tools), ( + f"Expected multiple different tools to be called in parallel, " + f"got: {parallel_response.tool_calls}" + ) + assert len(sequential_response.tool_calls) == 1, ( + f"Expected only one tool to be called in sequential mode, " + f"got: {sequential_response.tool_calls}" + ) + + async def test_parallel_and_sequential_tool_calling_async(self, model: BaseChatModel) -> None: + tools = [search_accommodation, search_flights, search_attractions] + parallel, sequential = self._bind_parallel_and_sequential(model, tools) + + parallel_response = await parallel.ainvoke(_PARALLEL_TOOL_PROMPT) + sequential_response = await sequential.ainvoke(_PARALLEL_TOOL_PROMPT) + + assert parallel_response.tool_calls is not None + assert sequential_response.tool_calls is not None + assert len(parallel_response.tool_calls) == len(tools), ( + f"Expected multiple different tools to be called in parallel, " + f"got: {parallel_response.tool_calls}" + ) + assert len(sequential_response.tool_calls) == 1, ( + f"Expected only one tool to be called in sequential mode, " + f"got: {sequential_response.tool_calls}" + ) + + def _build_file_input_structured_model(self) -> Runnable: + model = self._completions_class(**self.completions_kwargs) + return model.with_structured_output(InvoiceInfo) + + def _build_human_message(self, fmt: str) -> HumanMessage: + # `HumanMessage.content` is typed against `list[str | dict[Unknown, Unknown]]`; + # langchain happily accepts our concrete `list[dict[str, Any]]` at runtime + # but pyright can't widen across the invariant `list`, so cast. + blocks = cast(list[Any], build_human_content_block(fmt, load_fixture(fmt))) + return HumanMessage(content=blocks) + + @pytest.mark.parametrize("fmt", _FILE_INPUT_FORMATS) + def test_file_inputs(self, fmt: str) -> None: + """Round-trip a generated ``fmt`` file through the model via structured output.""" + result = self._build_file_input_structured_model().invoke([self._build_human_message(fmt)]) + _assert_invoice(result) + + @pytest.mark.parametrize("fmt", _FILE_INPUT_FORMATS) + async def test_file_inputs_async(self, fmt: str) -> None: + """Async path mirrors `test_file_inputs` for every supported format.""" + result = await self._build_file_input_structured_model().ainvoke( + [self._build_human_message(fmt)] + ) + _assert_invoice(result) diff --git a/uv.lock b/uv.lock index 3de86ec..87debb1 100644 --- a/uv.lock +++ b/uv.lock @@ -673,6 +673,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -1782,6 +1791,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, ] +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, + { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, + { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2227,6 +2338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/7b/45ad1b95315b5563baa7338c8e8088bb1af66905c46e1bd1fe6ecbe30ea8/openinference_semantic_conventions-0.1.29-py3-none-any.whl", hash = "sha256:f45e0b1cf79fe407af4722bcf391a01565f0878c95be3ebcc9382245d0367cc5", size = 10582, upload-time = "2026-04-22T00:39:27.066Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.41.0" @@ -2995,6 +3118,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -3177,6 +3313,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] +[[package]] +name = "reportlab" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/3f/b3861b7e40c9d66f4a04e018958d681d16b948bfd1963c962d43a8c23f66/reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494", size = 3939748, upload-time = "2026-05-12T10:14:13.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/45/ea7fad10122440de6e845568d106bffdc456ca0e8a1d8ae10b46016087e4/reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091", size = 1957812, upload-time = "2026-05-12T10:14:10.622Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -3676,10 +3825,14 @@ openai = [ dev = [ { name = "langchain-tests" }, { name = "openinference-instrumentation-langchain" }, + { name = "openpyxl" }, + { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-recording" }, + { name = "python-docx" }, + { name = "reportlab" }, { name = "ruff" }, { name = "uipath-langchain-client", extra = ["all"] }, { name = "uipath-llm-client", extra = ["all"] }, @@ -3707,10 +3860,14 @@ provides-extras = ["all", "anthropic", "google", "litellm", "openai"] dev = [ { name = "langchain-tests", specifier = ">=1.1.6,<2.0.0" }, { name = "openinference-instrumentation-langchain", specifier = ">=0.1.63,<1.0.0" }, + { name = "openpyxl", specifier = ">=3.1.5,<4.0.0" }, + { name = "pillow", specifier = ">=12.0.0,<13.0.0" }, { name = "pyright", specifier = ">=1.1.408,<2.0.0" }, { name = "pytest", specifier = ">=9.0.3,<10.0.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0,<2.0.0" }, { name = "pytest-recording", specifier = ">=0.13.4,<1.0.0" }, + { name = "python-docx", specifier = ">=1.1.2,<2.0.0" }, + { name = "reportlab", specifier = ">=4.2.5,<5.0.0" }, { name = "ruff", specifier = ">=0.15.11,<1.0.0" }, { name = "uipath-langchain-client", extras = ["all"], editable = "packages/uipath_langchain_client" }, { name = "uipath-llm-client", extras = ["all"], editable = "." },