Skip to content

Commit ba25d25

Browse files
authored
fix: keep the server_url path when splitting PDFs (#355)
## What PDF splitting now works when `server_url` includes a path, such as a self-hosted API behind `/deployment`. ``` server_url="https://host/deployment", split_pdf_page=True Before: GET https://host/general/docs → 404, partition fails After: GET https://host/deployment/general/docs → 200, pages processed ``` ## Why Before sending the split page requests, the SDK makes one extra request to the API. It built that URL from the host alone, dropping any path in `server_url`. It now builds that URL from the actual partition request, so a path in either the client's `server_url` or a per-call `server_url` is kept. The page requests themselves already used the right URL. The helper that built the old URL has no other callers, so it is removed along with its test. ## Validation Offline tests cover sync and async calls, with and without a per-call `server_url`, and plain, nested, and URL-encoded paths. Unit tests, pylint, and mypy pass. Adds a CHANGELOG entry and bumps the version to 0.46.3.
1 parent a073b7b commit ba25d25

7 files changed

Lines changed: 146 additions & 41 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.46.3
2+
3+
### Fixes
4+
* 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.
5+
16
## 0.46.2
27

38
### Fixes

‎_test_unstructured_client/unit/test_request_utils.py‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from unstructured_client._hooks.custom.request_utils import (
66
create_pdf_chunk_request_params,
7-
get_base_url,
87
get_multipart_stream_fields,
98
)
109

@@ -75,14 +74,3 @@ def test_create_pdf_chunk_request_params(input_form_data, page_number, expected_
7574
assert form_data == expected_form_data
7675

7776

78-
@pytest.mark.parametrize(
79-
("url", "expected_base_url"),
80-
[
81-
("https://api.unstructuredapp.io/general/v0/general", "https://api.unstructuredapp.io"),
82-
("https://api.unstructuredapp.io/general/v0/general?some_param=23", "https://api.unstructuredapp.io"),
83-
("http://localhost:3000/general/v0/general", "http://localhost:3000"),
84-
],
85-
)
86-
def test_get_base_url(url: str, expected_base_url: str):
87-
assert get_base_url(url) == expected_base_url
88-
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import asyncio
2+
import io
3+
4+
import httpx
5+
import pytest
6+
from pypdf import PdfWriter
7+
8+
from unstructured_client import UnstructuredClient
9+
from unstructured_client._hooks.custom.split_pdf_hook import _get_collection_url
10+
from unstructured_client.models import operations, shared
11+
from unstructured_client.utils import BackoffStrategy, RetryConfig
12+
13+
14+
PREFIXES = [
15+
"",
16+
"/deployment",
17+
"/one/two",
18+
"/general/v0/general/deployment",
19+
"/encoded%2Fprefix",
20+
]
21+
22+
23+
@pytest.fixture
24+
def pdf_bytes():
25+
document = PdfWriter()
26+
for _ in range(4):
27+
document.add_blank_page(width=612, height=792)
28+
buffer = io.BytesIO()
29+
document.write(buffer)
30+
return buffer.getvalue()
31+
32+
33+
@pytest.mark.parametrize("prefix", PREFIXES)
34+
def test_collection_url_preserves_encoded_prefix_and_replaces_terminal_route(prefix):
35+
url = httpx.URL(
36+
f"https://local.invalid{prefix}/general/v0/general?strategy=fast#fragment"
37+
)
38+
assert (
39+
str(_get_collection_url(url)) == f"https://local.invalid{prefix}/general/docs"
40+
)
41+
42+
43+
def make_sdk(monkeypatch, prefix, override):
44+
sent = []
45+
46+
def respond(request):
47+
sent.append((request.method, str(request.url)))
48+
return httpx.Response(200, json=[{"text": "hello"}], request=request)
49+
50+
transport = httpx.MockTransport(respond)
51+
original_async_client = httpx.AsyncClient
52+
sync_client = httpx.Client(transport=transport)
53+
async_client = original_async_client(transport=transport)
54+
sdk = UnstructuredClient(
55+
server_url="http://unused.invalid"
56+
if override
57+
else f"http://local.invalid{prefix}",
58+
client=sync_client,
59+
async_client=async_client,
60+
)
61+
# Split chunks create their own async HTTP client; keep every request offline.
62+
monkeypatch.setattr(
63+
httpx,
64+
"AsyncClient",
65+
lambda **kwargs: original_async_client(transport=transport, **kwargs),
66+
)
67+
return sdk, sent, sync_client, async_client
68+
69+
70+
def request(pdf_bytes, split):
71+
return operations.PartitionRequest(
72+
partition_parameters=shared.PartitionParameters(
73+
files=shared.Files(content=pdf_bytes, file_name="test.pdf"),
74+
split_pdf_page=split,
75+
)
76+
)
77+
78+
79+
def assert_routes(sent, prefix, split):
80+
partition = f"http://local.invalid{prefix}/general/v0/general"
81+
if split:
82+
assert sent[0] == ("GET", f"http://local.invalid{prefix}/general/docs")
83+
assert len(sent) == 3
84+
assert sent[1:] == [("POST", partition)] * 2
85+
else:
86+
assert sent == [("POST", partition)]
87+
88+
89+
@pytest.mark.parametrize("prefix", PREFIXES)
90+
@pytest.mark.parametrize("override", [False, True])
91+
@pytest.mark.parametrize("split", [False, True])
92+
def test_public_sync_sdk_preserves_deployment_routes(
93+
monkeypatch, pdf_bytes, prefix, override, split
94+
):
95+
sdk, sent, sync_client, async_client = make_sdk(monkeypatch, prefix, override)
96+
result = sdk.general.partition(
97+
request=request(pdf_bytes, split),
98+
server_url=f"http://local.invalid{prefix}" if override else None,
99+
retries=RetryConfig("none", BackoffStrategy(1, 1, 1, 1), False),
100+
)
101+
assert result.status_code == 200
102+
assert_routes(sent, prefix, split)
103+
sync_client.close()
104+
asyncio.run(async_client.aclose())
105+
106+
107+
@pytest.mark.asyncio
108+
@pytest.mark.parametrize("prefix", PREFIXES)
109+
@pytest.mark.parametrize("override", [False, True])
110+
@pytest.mark.parametrize("split", [False, True])
111+
async def test_public_async_sdk_preserves_deployment_routes(
112+
monkeypatch, pdf_bytes, prefix, override, split
113+
):
114+
sdk, sent, sync_client, async_client = make_sdk(monkeypatch, prefix, override)
115+
result = await sdk.general.partition_async(
116+
request=request(pdf_bytes, split),
117+
server_url=f"http://local.invalid{prefix}" if override else None,
118+
retries=RetryConfig("none", BackoffStrategy(1, 1, 1, 1), False),
119+
)
120+
assert result.status_code == 200
121+
assert_routes(sent, prefix, split)
122+
sync_client.close()
123+
await async_client.aclose()

‎_test_unstructured_client/unit/test_split_pdf_hook.py‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -725,14 +725,12 @@ def test_before_request_raises_pdf_validation_error_when_pdf_check_fails():
725725

726726
with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \
727727
patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \
728-
patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \
729-
patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url:
728+
patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf:
730729

731730
# Set up the mocks
732731
mock_get_fields.return_value = mock_form_data
733732
mock_read_pdf.return_value = mock_pdf_reader
734733
mock_check_pdf.side_effect = pdf_utils.PDFValidationError(error_message)
735-
mock_get_base_url.return_value = "http://localhost:8888"
736734

737735
# Call the method under test and verify it raises PDFValidationError
738736
with pytest.raises(pdf_utils.PDFValidationError) as exc_info:
@@ -857,14 +855,12 @@ def _make_hook_with_split_request(
857855
with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \
858856
patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \
859857
patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \
860-
patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url, \
861858
patch.object(hook, "_trim_large_pages", side_effect=lambda pdf, fd: pdf), \
862859
patch.object(hook, "_get_pdf_chunk_paths", return_value=[]), \
863860
patch.object(hook, "_get_pdf_chunks_in_memory", return_value=pdf_chunks or []):
864861
mock_get_fields.return_value = form_data
865862
mock_read_pdf.return_value = mock_pdf_reader
866863
mock_check_pdf.return_value = mock_pdf_reader
867-
mock_get_base_url.return_value = "http://localhost:8888"
868864

869865
result = hook.before_request(hook_ctx, request)
870866

@@ -2169,7 +2165,6 @@ def _chunk_paths_side_effect(*args, **kwargs):
21692165
with patch("unstructured_client._hooks.custom.request_utils.get_multipart_stream_fields") as mock_get_fields, \
21702166
patch("unstructured_client._hooks.custom.pdf_utils.read_pdf") as mock_read_pdf, \
21712167
patch("unstructured_client._hooks.custom.pdf_utils.check_pdf") as mock_check_pdf, \
2172-
patch("unstructured_client._hooks.custom.request_utils.get_base_url") as mock_get_base_url, \
21732168
patch("unstructured_client._hooks.custom.split_pdf_hook.futures.ThreadPoolExecutor", return_value=executor), \
21742169
patch("unstructured_client._hooks.custom.request_utils.create_pdf_chunk_request", side_effect=RuntimeError("chunk build failed")), \
21752170
patch.object(hook, "_trim_large_pages", side_effect=lambda pdf, fd: pdf), \
@@ -2178,7 +2173,6 @@ def _chunk_paths_side_effect(*args, **kwargs):
21782173
mock_get_fields.return_value = mock_form_data
21792174
mock_read_pdf.return_value = mock_pdf_reader
21802175
mock_check_pdf.return_value = mock_pdf_reader
2181-
mock_get_base_url.return_value = "http://localhost:8888"
21822176

21832177
with pytest.raises(RuntimeError, match="chunk build failed"):
21842178
hook.before_request(hook_ctx, request)

‎src/unstructured_client/_hooks/custom/request_utils.py‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
import os
88
import tempfile
99
from typing import Tuple, Any, BinaryIO, Optional, TextIO
10-
from urllib.parse import urlparse
1110

1211
import httpx
13-
from httpx import URL
1412
from httpx._multipart import DataField, FileField
1513

1614
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:
467465
)
468466
setattr(response, "_content", content)
469467
return response
470-
471-
472-
def get_base_url(url: str | URL) -> str:
473-
"""Extracts the base URL from the given URL.
474-
475-
Args:
476-
url: The URL.
477-
478-
Returns:
479-
The base URL.
480-
"""
481-
parsed_url = urlparse(str(url))
482-
return f"{parsed_url.scheme}://{parsed_url.netloc}"

‎src/unstructured_client/_hooks/custom/split_pdf_hook.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
PARTITION_FORM_SPLIT_PDF_PAGE_KEY,
3636
PARTITION_FORM_STARTING_PAGE_NUMBER_KEY,
3737
)
38-
from unstructured_client._hooks.custom.request_utils import get_base_url
3938
from unstructured_client._hooks.types import (
4039
AfterErrorContext,
4140
AfterErrorHook,
@@ -66,6 +65,17 @@
6665
SPLIT_PDF_HEADER_PREFIX = "X-Unstructured-Split-"
6766

6867

68+
def _get_collection_url(partition_url: httpx.URL) -> httpx.URL:
69+
"""Keep deployment prefixes when switching to the split collection route.
70+
71+
Use the encoded path and replace only the terminal partition route. The
72+
collection request does not inherit partition query parameters or fragments.
73+
"""
74+
path = partition_url.raw_path.split(b"?", 1)[0]
75+
collection_path = path.removesuffix(b"/general/v0/general") + b"/general/docs"
76+
return partition_url.copy_with(raw_path=collection_path, query=None, fragment=None)
77+
78+
6979
class ChunkExecutionError(Exception):
7080
def __init__(self, index: int, inner: BaseException):
7181
super().__init__(str(inner))
@@ -719,11 +729,11 @@ def _before_request_unlocked(
719729
the synthetic collection request; otherwise, the original request.
720730
"""
721731

722-
# Actually the general.partition operation overwrites the default client's base url (as
723-
# the platform operations do). Here we need to get the base url from the request object.
732+
# general.partition can override the client URL, so derive the collection URL
733+
# from the actual partition request, preserving its deployment prefix.
724734
if hook_ctx.operation_id != "partition":
725735
return request
726-
partition_base_url = get_base_url(request.url)
736+
collection_url = _get_collection_url(request.url)
727737

728738
if self.client is None:
729739
logger.warning("HTTP client not accessible! Continuing without splitting.")
@@ -926,7 +936,7 @@ def _before_request_unlocked(
926936
dummy_request_extensions[OPERATION_ID_EXTENSION_KEY] = operation_id
927937
return httpx.Request(
928938
"GET",
929-
f"{partition_base_url}/general/docs",
939+
collection_url,
930940
headers={"operation_id": operation_id},
931941
extensions=dummy_request_extensions,
932942
)

‎src/unstructured_client/_version.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import importlib.metadata
44

55
__title__: str = "unstructured-client"
6-
__version__: str = "0.46.2"
6+
__version__: str = "0.46.3"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.46.2 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.46.3 2.680.0 1.2.31 unstructured-client"
1010

1111
try:
1212
if __package__ is not None:

0 commit comments

Comments
 (0)