Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 0 additions & 12 deletions _test_unstructured_client/unit/test_request_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from unstructured_client._hooks.custom.request_utils import (
create_pdf_chunk_request_params,
get_base_url,
get_multipart_stream_fields,
)

Expand Down Expand Up @@ -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

123 changes: 123 additions & 0 deletions _test_unstructured_client/unit/test_split_collection_urls.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 1 addition & 7 deletions _test_unstructured_client/unit/test_split_pdf_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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), \
Expand All @@ -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)
Expand Down
15 changes: 0 additions & 15 deletions src/unstructured_client/_hooks/custom/request_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
20 changes: 15 additions & 5 deletions src/unstructured_client/_hooks/custom/split_pdf_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions src/unstructured_client/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading