From e89a31f20620cce4fdfdcd8d3f3096475fc797b9 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:21:31 -0400 Subject: [PATCH 1/2] Preserve deployment paths in split-PDF collection requests --- .../unit/test_split_collection_urls.py | 123 ++++++++++++++++++ .../_hooks/custom/split_pdf_hook.py | 20 ++- 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 _test_unstructured_client/unit/test_split_collection_urls.py diff --git a/_test_unstructured_client/unit/test_split_collection_urls.py b/_test_unstructured_client/unit/test_split_collection_urls.py new file mode 100644 index 00000000..99ab3fe9 --- /dev/null +++ b/_test_unstructured_client/unit/test_split_collection_urls.py @@ -0,0 +1,123 @@ +import asyncio +import io + +import httpx +import pytest +from pypdf import PdfWriter + +from unstructured_client import UnstructuredClient +from unstructured_client._hooks.custom.split_pdf_hook import _get_collection_url +from unstructured_client.models import operations, shared +from unstructured_client.utils import BackoffStrategy, RetryConfig + + +PREFIXES = [ + "", + "/deployment", + "/one/two", + "/general/v0/general/deployment", + "/encoded%2Fprefix", +] + + +@pytest.fixture +def pdf_bytes(): + document = PdfWriter() + for _ in range(4): + document.add_blank_page(width=612, height=792) + buffer = io.BytesIO() + document.write(buffer) + return buffer.getvalue() + + +@pytest.mark.parametrize("prefix", PREFIXES) +def test_collection_url_preserves_encoded_prefix_and_replaces_terminal_route(prefix): + url = httpx.URL( + f"https://local.invalid{prefix}/general/v0/general?strategy=fast#fragment" + ) + assert ( + str(_get_collection_url(url)) == f"https://local.invalid{prefix}/general/docs" + ) + + +def make_sdk(monkeypatch, prefix, override): + sent = [] + + def respond(request): + sent.append((request.method, str(request.url))) + return httpx.Response(200, json=[{"text": "hello"}], request=request) + + transport = httpx.MockTransport(respond) + original_async_client = httpx.AsyncClient + sync_client = httpx.Client(transport=transport) + async_client = original_async_client(transport=transport) + sdk = UnstructuredClient( + server_url="http://unused.invalid" + if override + else f"http://local.invalid{prefix}", + client=sync_client, + async_client=async_client, + ) + # Split chunks create their own async HTTP client; keep every request offline. + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kwargs: original_async_client(transport=transport, **kwargs), + ) + return sdk, sent, sync_client, async_client + + +def request(pdf_bytes, split): + return operations.PartitionRequest( + partition_parameters=shared.PartitionParameters( + files=shared.Files(content=pdf_bytes, file_name="test.pdf"), + split_pdf_page=split, + ) + ) + + +def assert_routes(sent, prefix, split): + partition = f"http://local.invalid{prefix}/general/v0/general" + if split: + assert sent[0] == ("GET", f"http://local.invalid{prefix}/general/docs") + assert len(sent) == 3 + assert sent[1:] == [("POST", partition)] * 2 + else: + assert sent == [("POST", partition)] + + +@pytest.mark.parametrize("prefix", PREFIXES) +@pytest.mark.parametrize("override", [False, True]) +@pytest.mark.parametrize("split", [False, True]) +def test_public_sync_sdk_preserves_deployment_routes( + monkeypatch, pdf_bytes, prefix, override, split +): + sdk, sent, sync_client, async_client = make_sdk(monkeypatch, prefix, override) + result = sdk.general.partition( + request=request(pdf_bytes, split), + server_url=f"http://local.invalid{prefix}" if override else None, + retries=RetryConfig("none", BackoffStrategy(1, 1, 1, 1), False), + ) + assert result.status_code == 200 + assert_routes(sent, prefix, split) + sync_client.close() + asyncio.run(async_client.aclose()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix", PREFIXES) +@pytest.mark.parametrize("override", [False, True]) +@pytest.mark.parametrize("split", [False, True]) +async def test_public_async_sdk_preserves_deployment_routes( + monkeypatch, pdf_bytes, prefix, override, split +): + sdk, sent, sync_client, async_client = make_sdk(monkeypatch, prefix, override) + result = await sdk.general.partition_async( + request=request(pdf_bytes, split), + server_url=f"http://local.invalid{prefix}" if override else None, + retries=RetryConfig("none", BackoffStrategy(1, 1, 1, 1), False), + ) + assert result.status_code == 200 + assert_routes(sent, prefix, split) + sync_client.close() + await async_client.aclose() diff --git a/src/unstructured_client/_hooks/custom/split_pdf_hook.py b/src/unstructured_client/_hooks/custom/split_pdf_hook.py index c0f6920f..cafa29dc 100644 --- a/src/unstructured_client/_hooks/custom/split_pdf_hook.py +++ b/src/unstructured_client/_hooks/custom/split_pdf_hook.py @@ -35,7 +35,6 @@ PARTITION_FORM_SPLIT_PDF_PAGE_KEY, PARTITION_FORM_STARTING_PAGE_NUMBER_KEY, ) -from unstructured_client._hooks.custom.request_utils import get_base_url from unstructured_client._hooks.types import ( AfterErrorContext, AfterErrorHook, @@ -66,6 +65,17 @@ SPLIT_PDF_HEADER_PREFIX = "X-Unstructured-Split-" +def _get_collection_url(partition_url: httpx.URL) -> httpx.URL: + """Keep deployment prefixes when switching to the split collection route. + + Use the encoded path and replace only the terminal partition route. The + collection request does not inherit partition query parameters or fragments. + """ + path = partition_url.raw_path.split(b"?", 1)[0] + collection_path = path.removesuffix(b"/general/v0/general") + b"/general/docs" + return partition_url.copy_with(raw_path=collection_path, query=None, fragment=None) + + class ChunkExecutionError(Exception): def __init__(self, index: int, inner: BaseException): super().__init__(str(inner)) @@ -719,11 +729,11 @@ def _before_request_unlocked( the synthetic collection request; otherwise, the original request. """ - # Actually the general.partition operation overwrites the default client's base url (as - # the platform operations do). Here we need to get the base url from the request object. + # general.partition can override the client URL, so derive the collection URL + # from the actual partition request, preserving its deployment prefix. if hook_ctx.operation_id != "partition": return request - partition_base_url = get_base_url(request.url) + collection_url = _get_collection_url(request.url) if self.client is None: logger.warning("HTTP client not accessible! Continuing without splitting.") @@ -926,7 +936,7 @@ def _before_request_unlocked( dummy_request_extensions[OPERATION_ID_EXTENSION_KEY] = operation_id return httpx.Request( "GET", - f"{partition_base_url}/general/docs", + collection_url, headers={"operation_id": operation_id}, extensions=dummy_request_extensions, ) From f2d0372ce6c6b8f55f7950a6fb946ac084eff619 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:55:40 -0400 Subject: [PATCH 2/2] Remove unused get_base_url and release 0.46.3 --- CHANGELOG.md | 5 +++++ .../unit/test_request_utils.py | 12 ------------ .../unit/test_split_pdf_hook.py | 8 +------- .../_hooks/custom/request_utils.py | 15 --------------- src/unstructured_client/_version.py | 4 ++-- 5 files changed, 8 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a916ba82..c8c6a13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.46.3 + +### Fixes +* Keep the deployment path in split-PDF requests. With a `server_url` that includes a path, such as `https://host/deployment`, splitting a PDF sent its collection request to `https://host/general/docs` instead of `https://host/deployment/general/docs`, which returned 404 before any chunks were processed. The collection URL is now derived from the partition request by replacing only its trailing `/general/v0/general` route, for sync and async calls and for a `server_url` passed to the operation. + ## 0.46.2 ### Fixes diff --git a/_test_unstructured_client/unit/test_request_utils.py b/_test_unstructured_client/unit/test_request_utils.py index 72b7c04b..8f1d734f 100644 --- a/_test_unstructured_client/unit/test_request_utils.py +++ b/_test_unstructured_client/unit/test_request_utils.py @@ -4,7 +4,6 @@ from unstructured_client._hooks.custom.request_utils import ( create_pdf_chunk_request_params, - get_base_url, get_multipart_stream_fields, ) @@ -75,14 +74,3 @@ def test_create_pdf_chunk_request_params(input_form_data, page_number, expected_ assert form_data == expected_form_data -@pytest.mark.parametrize( - ("url", "expected_base_url"), - [ - ("https://api.unstructuredapp.io/general/v0/general", "https://api.unstructuredapp.io"), - ("https://api.unstructuredapp.io/general/v0/general?some_param=23", "https://api.unstructuredapp.io"), - ("http://localhost:3000/general/v0/general", "http://localhost:3000"), - ], -) -def test_get_base_url(url: str, expected_base_url: str): - assert get_base_url(url) == expected_base_url - diff --git a/_test_unstructured_client/unit/test_split_pdf_hook.py b/_test_unstructured_client/unit/test_split_pdf_hook.py index 9a69d9a8..be9ca472 100644 --- a/_test_unstructured_client/unit/test_split_pdf_hook.py +++ b/_test_unstructured_client/unit/test_split_pdf_hook.py @@ -725,14 +725,12 @@ def test_before_request_raises_pdf_validation_error_when_pdf_check_fails(): with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \ patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \ - patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \ - patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url: + patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf: # Set up the mocks mock_get_fields.return_value = mock_form_data mock_read_pdf.return_value = mock_pdf_reader mock_check_pdf.side_effect = pdf_utils.PDFValidationError(error_message) - mock_get_base_url.return_value = "http://localhost:8888" # Call the method under test and verify it raises PDFValidationError with pytest.raises(pdf_utils.PDFValidationError) as exc_info: @@ -857,14 +855,12 @@ def _make_hook_with_split_request( with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \ patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \ patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \ - patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url, \ patch.object(hook, "_trim_large_pages", side_effect=lambda pdf, fd: pdf), \ patch.object(hook, "_get_pdf_chunk_paths", return_value=[]), \ patch.object(hook, "_get_pdf_chunks_in_memory", return_value=pdf_chunks or []): mock_get_fields.return_value = form_data mock_read_pdf.return_value = mock_pdf_reader mock_check_pdf.return_value = mock_pdf_reader - mock_get_base_url.return_value = "http://localhost:8888" result = hook.before_request(hook_ctx, request) @@ -2169,7 +2165,6 @@ def _chunk_paths_side_effect(*args, **kwargs): with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \ patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \ patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \ - patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url, \ patch("unstructured_client._hooks.custom.split_pdf_hook.futures.ThreadPoolExecutor", return_value=executor), \ patch("unstructured_client._hooks.custom.request_utils.create_pdf_chunk_request", side_effect=RuntimeError("chunk build failed")), \ patch.object(hook, "_trim_large_pages", side_effect=lambda pdf, fd: pdf), \ @@ -2178,7 +2173,6 @@ def _chunk_paths_side_effect(*args, **kwargs): mock_get_fields.return_value = mock_form_data mock_read_pdf.return_value = mock_pdf_reader mock_check_pdf.return_value = mock_pdf_reader - mock_get_base_url.return_value = "http://localhost:8888" with pytest.raises(RuntimeError, match="chunk build failed"): hook.before_request(hook_ctx, request) diff --git a/src/unstructured_client/_hooks/custom/request_utils.py b/src/unstructured_client/_hooks/custom/request_utils.py index f15c3580..96382d4a 100644 --- a/src/unstructured_client/_hooks/custom/request_utils.py +++ b/src/unstructured_client/_hooks/custom/request_utils.py @@ -7,10 +7,8 @@ import os import tempfile from typing import Tuple, Any, BinaryIO, Optional, TextIO -from urllib.parse import urlparse import httpx -from httpx import URL from httpx._multipart import DataField, FileField from unstructured_client._hooks.custom.common import UNSTRUCTURED_CLIENT_LOGGER_NAME @@ -467,16 +465,3 @@ def create_elements_file_response(elements_file: str) -> httpx.Response: ) setattr(response, "_content", content) return response - - -def get_base_url(url: str | URL) -> str: - """Extracts the base URL from the given URL. - - Args: - url: The URL. - - Returns: - The base URL. - """ - parsed_url = urlparse(str(url)) - return f"{parsed_url.scheme}://{parsed_url.netloc}" diff --git a/src/unstructured_client/_version.py b/src/unstructured_client/_version.py index 85d5419c..a4d5118a 100644 --- a/src/unstructured_client/_version.py +++ b/src/unstructured_client/_version.py @@ -3,10 +3,10 @@ import importlib.metadata __title__: str = "unstructured-client" -__version__: str = "0.46.2" +__version__: str = "0.46.3" __openapi_doc_version__: str = "1.2.31" __gen_version__: str = "2.680.0" -__user_agent__: str = "speakeasy-sdk/python 0.46.2 2.680.0 1.2.31 unstructured-client" +__user_agent__: str = "speakeasy-sdk/python 0.46.3 2.680.0 1.2.31 unstructured-client" try: if __package__ is not None: