Skip to content

Commit ed8164e

Browse files
Stream PDF from disk, s3 or zip files through memory (#124)
process_pdf could only read the document from disk, so callers holding a PDF in memory - fetched from an API, read out of a database or an object store - had to write it to a temporary file only for the client to open it again. It now takes the document itself as well: bytes, or any binary stream. Nothing says which of the two it is; the object does. A document also names itself, from the "name" attribute open() sets on files and that can be set on anything else, io.BytesIO included, so the identity of a document is not lost by going through memory - it travels with the request and comes back with the result. Bytes on their own have nothing to be named after and fall back to DEFAULT_IN_MEMORY_NAME. A stream is read once, up front, and re-served from memory afterwards: the 503 retry sends the same document again, and a consumed (or non-seekable) stream would silently post an empty body the second time around. That is also why the retry no longer recurses through the public entry point, which would have had to re-derive a name from a source that is by then exhausted. process_documents processes several of them concurrently, through the same ThreadPoolExecutor the file-based processing uses. Results come back in input order rather than in completion order: in-memory documents have no filenames to be matched back on afterwards, so the caller has nothing but the order to zip them onto. A single PDF passed by mistake raises instead of being iterated, which would otherwise send one request per byte. An in-memory run keeps up to n documents in flight against the server, so a client concurrency above the server's engine pool only piles up requests that queue there or come back as 503, while one below it leaves engines idle. Neither is visible from the client side until the throughput disappoints. Before process_documents and the in-memory archive/s3 streaming start, the client now asks /api/health how many engines the server has (pool.maxActive) and logs a warning when n exceeds them - with the number to use instead - and an info message when they outnumber n. The check is advisory, not a gate: a server without the endpoint (older GROBID), an unreadable answer or a connection failure never blocks the run. A server answering ready: false is also surfaced as a warning. Completes what #117 left pending on #67. --------- Co-authored-by: Jan Göpfert <94385965+jangoepfert@users.noreply.github.com>
1 parent a936d3c commit ed8164e

4 files changed

Lines changed: 991 additions & 45 deletions

File tree

Readme.md

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ concurrent processing capabilities for PDF documents, reference strings, and pat
3636
- **Type Hints**: Ships inline type annotations and a `py.typed` marker (PEP 561) for static type checking
3737
- **Archive Streaming**: Process files directly from `.zip`/`.tar`/`.tar.gz` archives without fully decompressing them
3838
- **S3 Streaming**: Read PDFs and zips straight from `s3://` (range-streamed, no full download) with the optional `[s3]` extra
39+
- **In-Memory Documents**: Send PDFs held as bytes straight to GROBID, without writing them to disk first
3940

4041
## 📋 Prerequisites
4142

@@ -188,16 +189,17 @@ grobid_client --input "~/data/**/*.pdf" --output ~/results processFulltextDocu
188189

189190
> [!NOTE]
190191
> `--input` accepts a directory, a single file, an **archive**, or a **glob pattern**:
191-
> - **Archives** (`.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`) are streamed: eligible entries are extracted in
192-
> chunks of `batch_size` to a temporary directory, sent to GROBID, written to `--output`, and deleted before the next
193-
> chunk. The archive is never fully decompressed, so disk usage stays bounded. If `--output` is omitted, results go to a
194-
> directory named after the archive (e.g. `papers.zip``papers/`).
192+
> - **Archives** (`.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`) are streamed: eligible entries are read into
193+
> memory in chunks of `batch_size` and sent to GROBID straight from there — the archive is never fully decompressed and
194+
> nothing but the results in `--output` ever touches the disk. If `--output` is omitted, results go to a directory named
195+
> after the archive (e.g. `papers.zip``papers/`).
195196
> - **Glob patterns** (`paper.zip`, `paper*.zip`, `**/paper*.zip`, `**/*.pdf`, …) are expanded with `**` recursion; each
196197
> match is handled by type (archive → streamed, directory → recursed, file → processed). Quote the pattern so your shell
197198
> passes it through to the client unexpanded.
198199
> - **S3** (requires `pip install "grobid-client-python[s3]"`): pass an `s3://` object, prefix or glob. A remote zip is
199200
> **range-streamed** (only its central directory and the entries are fetched — never the whole object); loose remote
200-
> PDFs are fetched a batch at a time. Credentials use the standard AWS chain (env vars / `~/.aws` / IAM role).
201+
> PDFs are fetched a batch at a time, directly into memory without being written to a local file first. Credentials use
202+
> the standard AWS chain (env vars / `~/.aws` / IAM role).
201203
> ```bash
202204
> grobid_client --input "s3://my-bucket/papers/2021.zip" --output ~/out processFulltextDocument # one remote zip
203205
> grobid_client --input "s3://my-bucket/pdfs/*.pdf" --output ~/out processFulltextDocument # loose PDFs
@@ -289,6 +291,63 @@ client.process(
289291
)
290292
```
291293

294+
#### Processing a PDF from memory
295+
296+
A PDF that is already in memory - downloaded from an API, read from a database or an object store - can be sent
297+
directly, without writing it to a temporary file first. `process_pdf` takes either a path or the document itself, as
298+
`bytes` or as any binary stream, and returns the TEI as a string:
299+
300+
```python
301+
import io
302+
import requests
303+
304+
pdf = io.BytesIO(requests.get("https://example.org/paper.pdf").content)
305+
pdf.name = "paper.pdf" # optional, see below
306+
307+
name, status, tei = client.process_pdf(
308+
service="processFulltextDocument",
309+
pdf_file=pdf,
310+
consolidate_header=True,
311+
tei_coordinates=True
312+
)
313+
314+
if status == 200:
315+
print(tei)
316+
```
317+
318+
There is no flag to say where the document comes from: the object itself says it. A document also carries its own name,
319+
taken from the `name` attribute that `open()` sets on files and that can be set on anything else, `io.BytesIO` included.
320+
The name identifies the document in the request sent to GROBID, in the logs, and as the first element of the result, so
321+
documents processed this way stay distinguishable. Bytes passed on their own have nothing to be named after and fall
322+
back to `document.pdf`.
323+
324+
Several documents can be sent concurrently with `process_documents`, which runs them through the same thread pool the
325+
file-based processing uses:
326+
327+
```python
328+
results = client.process_documents(
329+
service="processFulltextDocument",
330+
documents=[pdf1, pdf2, "/path/to/paper3.pdf"],
331+
n=10 # documents sent concurrently
332+
)
333+
334+
for name, status, tei in results:
335+
...
336+
```
337+
338+
Documents that do not name themselves are named `document-1.pdf`, `document-2.pdf`, ... after their position. Results
339+
come back **in the order the documents were given**, not in completion order, so they can be zipped back onto whatever
340+
the caller has them keyed by. A document that fails does not stop the others: its own entry carries the error status.
341+
342+
Before an in-memory run starts (this includes archive and `s3://` streaming), the client asks the server's `/api/health`
343+
how many engines it actually has and logs a warning when the requested concurrency `n` exceeds them - the surplus
344+
requests would only queue on the server or bounce as 503 - and an info message when engines would sit idle. The check is
345+
advisory: a server without the endpoint (older GROBID) never blocks the run.
346+
347+
> [!NOTE]
348+
> Both return the TEI instead of writing it to disk, so the caller decides what to do with it. Use `process()` for the
349+
> directory-oriented processing with resume and JSON/Markdown conversion.
350+
292351
### Standalone Conversion Tools
293352

294353
The library includes standalone scripts to convert TEI XML files to other formats without using the main client or server.

0 commit comments

Comments
 (0)