Skip to content

Commit 0f19496

Browse files
Add download and streaming support with comprehensive tests
- Implemented helper methods for reading bytes, text, and JSON from downloads. - Added streaming utilities with both synchronous and asynchronous implementations. - Updated tests with static and live integration cases to validate file download and streaming functionalities. Assisted-by: Codex
1 parent 0829459 commit 0f19496

2 files changed

Lines changed: 681 additions & 8 deletions

File tree

src/pdfrest/client.py

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import json
67
import os
78
import uuid
8-
from collections.abc import Mapping, Sequence
9+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
910
from contextlib import ExitStack
1011
from os import PathLike
1112
from pathlib import Path
@@ -54,6 +55,7 @@
5455
UrlValue = str | URL
5556
UrlInput = UrlValue | Sequence[UrlValue]
5657
NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4
58+
DestinationPath = str | PathLike[str]
5759

5860

5961
def _extract_uploaded_file_ids(payload: Any) -> list[str]:
@@ -212,6 +214,10 @@ def _normalize_url_inputs(urls: UrlInput) -> list[str]:
212214
return normalized
213215

214216

217+
def _resolve_file_id(file_ref: PdfRestFile | str) -> str:
218+
return file_ref.id if isinstance(file_ref, PdfRestFile) else str(file_ref)
219+
220+
215221
ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient)
216222

217223

@@ -572,6 +578,19 @@ def _send_request(self, request: _RequestModel) -> Any:
572578
def send_request(self, request: _RequestModel) -> Any:
573579
return self._send_request(request)
574580

581+
def download_file(self, file_id: str) -> httpx.Response:
582+
request = self._client.build_request("GET", f"/resource/{file_id}")
583+
try:
584+
response = self._client.send(request, stream=True)
585+
except httpx.HTTPError as exc:
586+
raise translate_httpx_error(exc) from exc
587+
if not response.is_success:
588+
try:
589+
self._handle_response(response)
590+
finally:
591+
response.close()
592+
return response
593+
575594
def fetch_file_info(self, file_id: str) -> PdfRestFile:
576595
request = self.prepare_request(
577596
"GET",
@@ -641,6 +660,19 @@ async def _send_request(self, request: _RequestModel) -> Any:
641660
async def send_request(self, request: _RequestModel) -> Any:
642661
return await self._send_request(request)
643662

663+
async def download_file(self, file_id: str) -> httpx.Response:
664+
request = self._client.build_request("GET", f"/resource/{file_id}")
665+
try:
666+
response = await self._client.send(request, stream=True)
667+
except httpx.HTTPError as exc:
668+
raise translate_httpx_error(exc) from exc
669+
if not response.is_success:
670+
try:
671+
self._handle_response(response)
672+
finally:
673+
await response.aclose()
674+
return response
675+
644676
async def fetch_file_info(self, file_id: str) -> PdfRestFile:
645677
request = self.prepare_request(
646678
"GET",
@@ -651,6 +683,66 @@ async def fetch_file_info(self, file_id: str) -> PdfRestFile:
651683
return PdfRestFile.model_validate(payload)
652684

653685

686+
class PdfRestFileStream:
687+
"""Streaming wrapper for synchronously downloading files from pdfRest."""
688+
689+
def __init__(self, response: httpx.Response) -> None:
690+
self._response = response
691+
692+
def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]:
693+
yield from self._response.iter_bytes(chunk_size)
694+
695+
def iter_text(self, chunk_size: int | None = None) -> Iterator[str]:
696+
yield from self._response.iter_text(chunk_size)
697+
698+
def iter_lines(self) -> Iterator[str]:
699+
yield from self._response.iter_lines()
700+
701+
def iter_raw(self, chunk_size: int | None = None) -> Iterator[bytes]:
702+
yield from self._response.iter_raw(chunk_size)
703+
704+
def close(self) -> None:
705+
self._response.close()
706+
707+
def __enter__(self) -> PdfRestFileStream:
708+
return self
709+
710+
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
711+
self.close()
712+
713+
714+
class AsyncPdfRestFileStream:
715+
"""Streaming wrapper for asynchronously downloading files from pdfRest."""
716+
717+
def __init__(self, response: httpx.Response) -> None:
718+
self._response = response
719+
720+
async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
721+
async for chunk in self._response.aiter_bytes(chunk_size):
722+
yield chunk
723+
724+
async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]:
725+
async for chunk in self._response.aiter_text(chunk_size):
726+
yield chunk
727+
728+
async def iter_lines(self) -> AsyncIterator[str]:
729+
async for line in self._response.aiter_lines():
730+
yield line
731+
732+
async def iter_raw(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
733+
async for chunk in self._response.aiter_raw(chunk_size):
734+
yield chunk
735+
736+
async def close(self) -> None:
737+
await self._response.aclose()
738+
739+
async def __aenter__(self) -> AsyncPdfRestFileStream:
740+
return self
741+
742+
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
743+
await self.close()
744+
745+
654746
class _FilesClient:
655747
"""Expose file-related operations for the synchronous client."""
656748

@@ -709,6 +801,56 @@ def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]:
709801
file_ids = _extract_uploaded_file_ids(payload)
710802
return [self._client.fetch_file_info(file_id) for file_id in file_ids]
711803

804+
def read_bytes(self, file_ref: PdfRestFile | str) -> bytes:
805+
response = self._client.download_file(_resolve_file_id(file_ref))
806+
try:
807+
return response.read()
808+
finally:
809+
response.close()
810+
811+
def read_text(
812+
self,
813+
file_ref: PdfRestFile | str,
814+
*,
815+
encoding: str = "utf-8",
816+
) -> str:
817+
response = self._client.download_file(_resolve_file_id(file_ref))
818+
try:
819+
response.encoding = encoding
820+
data = response.read()
821+
codec = response.encoding or encoding or "utf-8"
822+
return data.decode(codec)
823+
finally:
824+
response.close()
825+
826+
def read_json(self, file_ref: PdfRestFile | str) -> Any:
827+
response = self._client.download_file(_resolve_file_id(file_ref))
828+
try:
829+
data = response.read()
830+
codec = response.encoding or "utf-8"
831+
return json.loads(data.decode(codec))
832+
finally:
833+
response.close()
834+
835+
def write_bytes(
836+
self,
837+
file_ref: PdfRestFile | str,
838+
destination: DestinationPath,
839+
) -> Path:
840+
response = self._client.download_file(_resolve_file_id(file_ref))
841+
path = Path(destination)
842+
try:
843+
with path.open("wb") as file_handle:
844+
for chunk in response.iter_bytes():
845+
file_handle.write(chunk)
846+
finally:
847+
response.close()
848+
return path
849+
850+
def stream(self, file_ref: PdfRestFile | str) -> PdfRestFileStream:
851+
response = self._client.download_file(_resolve_file_id(file_ref))
852+
return PdfRestFileStream(response)
853+
712854

713855
class _AsyncFilesClient:
714856
"""Expose file-related operations for the asynchronous client."""
@@ -786,6 +928,56 @@ async def fetch(file_id: str) -> PdfRestFile:
786928

787929
return await asyncio.gather(*(fetch(file_id) for file_id in file_ids))
788930

931+
async def read_bytes(self, file_ref: PdfRestFile | str) -> bytes:
932+
response = await self._client.download_file(_resolve_file_id(file_ref))
933+
try:
934+
return await response.aread()
935+
finally:
936+
await response.aclose()
937+
938+
async def read_text(
939+
self,
940+
file_ref: PdfRestFile | str,
941+
*,
942+
encoding: str = "utf-8",
943+
) -> str:
944+
response = await self._client.download_file(_resolve_file_id(file_ref))
945+
try:
946+
response.encoding = encoding
947+
data = await response.aread()
948+
codec = response.encoding or encoding or "utf-8"
949+
return data.decode(codec)
950+
finally:
951+
await response.aclose()
952+
953+
async def read_json(self, file_ref: PdfRestFile | str) -> Any:
954+
response = await self._client.download_file(_resolve_file_id(file_ref))
955+
try:
956+
data = await response.aread()
957+
codec = response.encoding or "utf-8"
958+
return json.loads(data.decode(codec))
959+
finally:
960+
await response.aclose()
961+
962+
async def write_bytes(
963+
self,
964+
file_ref: PdfRestFile | str,
965+
destination: DestinationPath,
966+
) -> Path:
967+
response = await self._client.download_file(_resolve_file_id(file_ref))
968+
path = Path(destination)
969+
try:
970+
with path.open("wb") as file_handle:
971+
async for chunk in response.aiter_bytes():
972+
file_handle.write(chunk)
973+
finally:
974+
await response.aclose()
975+
return path
976+
977+
async def stream(self, file_ref: PdfRestFile | str) -> AsyncPdfRestFileStream:
978+
response = await self._client.download_file(_resolve_file_id(file_ref))
979+
return AsyncPdfRestFileStream(response)
980+
789981

790982
class PdfRestClient(_SyncApiClient):
791983
"""Synchronous client for interacting with the pdfrest API."""

0 commit comments

Comments
 (0)