Skip to content

Commit 2c88959

Browse files
authored
Merge pull request #6 from afriedman412/plugin-options
can now pass params through to plugins
2 parents 75084ee + 549c1be commit 2c88959

4 files changed

Lines changed: 100 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ All fields are nullable — Petey returns `null` for anything it can't find rath
183183
| `model` | LLM model ID (e.g. `gpt-4.1-mini`, `claude-sonnet-4-6`). Overridden by `--model` or `PETEY_MODEL` env var. |
184184
| `parser` | Text extraction backend: `pymupdf` (default), `tables`, `pdfplumber`, `tabula`, `marker`, `llamaparse`, or `docling`. Overridden by `--parser`. |
185185
| `ocr` | OCR backend: `none` (default), `tesseract`, `mistral`, `chandra`. Overridden by `--ocr`. |
186+
| `parser_options` | Dict of extra kwargs passed to the parser (e.g. `languages: [en, fr]`) |
187+
| `ocr_options` | Dict of extra kwargs passed to the OCR backend |
186188
| `instructions` | Extra guidance appended to the prompt (e.g. "ignore the summary row") |
187189
| `header_pages` | Number of leading pages to treat as a document header (see below) |
188190
| `pages` | Page range to process, e.g. `"2-5"` or `"1,3,5-7"` (1-indexed) |

petey/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ def on_result(path, data):
283283
print(line)
284284

285285
instructions = spec.get("instructions", "")
286+
parser_options = spec.get("parser_options") or None
287+
ocr_options = spec.get("ocr_options") or None
286288

287289
# CLI overrides schema, schema overrides defaults
288290
ocr_backend = (
@@ -362,6 +364,8 @@ def on_chunk(label, data, _name=pdf_name):
362364
on_result=on_chunk,
363365
parser=parser,
364366
ocr_backend=ocr_backend,
367+
parser_options=parser_options,
368+
ocr_options=ocr_options,
365369
header_pages=(
366370
args.header_pages
367371
if args.header_pages is not None
@@ -393,6 +397,8 @@ def on_chunk(label, data, _name=pdf_name):
393397
on_result=on_result,
394398
parser=parser,
395399
ocr_backend=ocr_backend,
400+
parser_options=parser_options,
401+
ocr_options=ocr_options,
396402
)
397403
)
398404

petey/extract.py

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -484,26 +484,26 @@ def extract_text(
484484
parser: str = "pymupdf",
485485
ocr_fallback: bool = False,
486486
ocr_backend: str = "none",
487+
parser_options: dict | None = None,
488+
ocr_options: dict | None = None,
487489
) -> str:
488490
"""Extract all text from a PDF as a single string.
489491
490492
Args:
491493
pdf_path: Path to the PDF file.
492-
parser: Text extraction backend.
493-
"pymupdf" — markdown via pymupdf4llm (default)
494-
"tables" — PyMuPDF table detection → TSV
495-
"pdfplumber" — layout-preserving spatial extraction
496-
"tabula" — tabula-py table extraction → TSV (requires Java)
494+
parser: Text extraction backend — see ``PARSERS`` registry.
497495
ocr_fallback: Deprecated. Use ``ocr_backend="tesseract"`` instead.
498496
ocr_backend: OCR fallback when text layer is empty/short.
499-
"none" — no OCR (default)
500-
"tesseract" — local pytesseract (requires tesseract binary)
501-
"mistral" — Mistral OCR API (requires MISTRAL_API_KEY)
497+
parser_options: Extra kwargs passed to the parser callable.
498+
ocr_options: Extra kwargs passed to the OCR callable.
502499
503500
Returns:
504501
Extracted text as a single string.
505502
"""
506-
pages = extract_text_pages(pdf_path, parser, ocr_fallback, ocr_backend)
503+
pages = extract_text_pages(
504+
pdf_path, parser, ocr_fallback, ocr_backend,
505+
parser_options=parser_options, ocr_options=ocr_options,
506+
)
507507
return "\n\n".join(pages)
508508

509509

@@ -512,6 +512,8 @@ def extract_text_pages(
512512
parser: str = "pymupdf",
513513
ocr_fallback: bool = False,
514514
ocr_backend: str = "none",
515+
parser_options: dict | None = None,
516+
ocr_options: dict | None = None,
515517
) -> list[str]:
516518
"""Extract text from each page of a PDF separately.
517519
@@ -521,11 +523,15 @@ def extract_text_pages(
521523
ocr_fallback: Deprecated. Use ``ocr_backend="tesseract"`` instead.
522524
ocr_backend: OCR fallback when text layer is empty/short —
523525
see ``OCR_BACKENDS`` registry.
526+
parser_options: Extra kwargs passed to the parser callable.
527+
ocr_options: Extra kwargs passed to the OCR callable.
524528
525529
Returns:
526530
List of strings, one per page.
527531
"""
528532
ocr_backend = _resolve_ocr_backend(ocr_fallback, ocr_backend)
533+
_p_opts = parser_options or {}
534+
_o_opts = ocr_options or {}
529535

530536
fn = PARSERS.get(parser)
531537
if fn is None:
@@ -550,9 +556,9 @@ def extract_text_pages(
550556
f"extract_async() or "
551557
f"extract_pages_async() instead."
552558
)
553-
pages = asyncio.run(fn(pdf_path))
559+
pages = asyncio.run(fn(pdf_path, **_p_opts))
554560
else:
555-
pages = fn(pdf_path)
561+
pages = fn(pdf_path, **_p_opts)
556562

557563
# Apply OCR fallback to pages with insufficient text
558564
if ocr_backend != "none":
@@ -561,7 +567,10 @@ def extract_text_pages(
561567
if len(text.strip()) < OCR_THRESHOLD:
562568
if doc is None:
563569
doc = fitz.open(pdf_path)
564-
pages[i] = _ocr_page(doc[i], ocr_backend, pdf_path)
570+
pages[i] = _ocr_page(
571+
doc[i], ocr_backend, pdf_path,
572+
**_o_opts,
573+
)
565574

566575
return pages
567576

@@ -599,13 +608,15 @@ def _ocr_page_tesseract(page) -> str:
599608
def _ocr_page(
600609
page, ocr_backend: str = "tesseract",
601610
pdf_path: str | None = None,
611+
**ocr_options,
602612
) -> str:
603613
"""OCR a single fitz page.
604614
605615
Args:
606616
page: A fitz page object.
607617
ocr_backend: OCR backend — see ``OCR_BACKENDS`` registry.
608618
pdf_path: Path to the source PDF (used by Mistral OCR).
619+
**ocr_options: Extra kwargs passed to the OCR callable.
609620
610621
Returns:
611622
Extracted text from the page.
@@ -627,8 +638,8 @@ def _ocr_page(
627638
f"Use extract_async() or "
628639
f"extract_pages_async() instead."
629640
)
630-
return asyncio.run(fn(page))
631-
return fn(page)
641+
return asyncio.run(fn(page, **ocr_options))
642+
return fn(page, **ocr_options)
632643

633644

634645
def _ocr_full(doc, pdf_path: str, ocr_backend: str) -> str:
@@ -831,6 +842,8 @@ async def extract_async(
831842
llm_backend: str | None = None,
832843
text: str | None = None,
833844
parse_fn=None,
845+
parser_options: dict | None = None,
846+
ocr_options: dict | None = None,
834847
) -> BaseModel:
835848
"""Extract structured data from a single PDF.
836849
@@ -852,6 +865,7 @@ async def extract_async(
852865
Returns:
853866
Populated Pydantic model instance.
854867
"""
868+
_p_opts = parser_options or {}
855869
mgr = get_manager()
856870
if text is None:
857871
if parse_fn is not None:
@@ -866,13 +880,14 @@ async def extract_async(
866880
parser_fn
867881
):
868882
pages = await mgr.run(
869-
parser_fn, pdf_path,
883+
parser_fn, pdf_path, **_p_opts,
870884
)
871885
text = "\n\n".join(pages)
872886
else:
873887
text = await mgr.run_cpu(
874888
extract_text, pdf_path, parser,
875889
ocr_fallback, ocr_backend,
890+
_p_opts, ocr_options,
876891
)
877892
if len(text) > TEXT_WARN_THRESHOLD:
878893
warnings.warn(
@@ -945,6 +960,8 @@ def extract(
945960
ocr_fallback: bool = False,
946961
ocr_backend: str = "none",
947962
llm_backend: str | None = None,
963+
parser_options: dict | None = None,
964+
ocr_options: dict | None = None,
948965
) -> BaseModel:
949966
"""Sync wrapper around ``extract_async``. See that function for args."""
950967
return asyncio.run(
@@ -953,6 +970,7 @@ def extract(
953970
model=model, api_key=api_key, instructions=instructions,
954971
parser=parser, ocr_fallback=ocr_fallback,
955972
ocr_backend=ocr_backend, llm_backend=llm_backend,
973+
parser_options=parser_options, ocr_options=ocr_options,
956974
)
957975
)
958976

@@ -1132,6 +1150,8 @@ async def extract_pages_async(
11321150
page_range: str | None = None,
11331151
parse_multiplier: int = 5,
11341152
parse_fn=None,
1153+
parser_options: dict | None = None,
1154+
ocr_options: dict | None = None,
11351155
) -> list[dict]:
11361156
"""Split a PDF into page chunks and extract each concurrently.
11371157
@@ -1162,6 +1182,8 @@ async def extract_pages_async(
11621182
parse_fn: Optional async callable(pdf_path, page_index, parser,
11631183
ocr_backend) -> str. When provided, replaces local
11641184
page-level text extraction (no ProcessPoolExecutor used).
1185+
parser_options: Extra kwargs passed to the parser callable.
1186+
ocr_options: Extra kwargs passed to the OCR callable.
11651187
11661188
Returns:
11671189
List of result dicts (with _page and optionally _error).
@@ -1175,6 +1197,8 @@ async def extract_pages_async(
11751197
stacklevel=2,
11761198
)
11771199

1200+
_p_opts = parser_options or {}
1201+
11781202
# Resolve parser from registry
11791203
parser_fn = PARSERS.get(parser)
11801204
if parser_fn is None:
@@ -1210,11 +1234,11 @@ async def extract_pages_async(
12101234
try:
12111235
if parser_is_async:
12121236
h_pages = await mgr.run(
1213-
parser_fn, h_path,
1237+
parser_fn, h_path, **_p_opts,
12141238
)
12151239
else:
12161240
h_pages = await mgr.run_cpu(
1217-
parser_fn, h_path,
1241+
parser_fn, h_path, **_p_opts,
12181242
)
12191243
header_text = "\n\n".join(h_pages)
12201244
finally:
@@ -1270,11 +1294,11 @@ async def _parse_chunk(idx_slice):
12701294
try:
12711295
if parser_is_async:
12721296
pages = await mgr.run(
1273-
parser_fn, chunk_path,
1297+
parser_fn, chunk_path, **_p_opts,
12741298
)
12751299
else:
12761300
pages = await mgr.run_cpu(
1277-
parser_fn, chunk_path,
1301+
parser_fn, chunk_path, **_p_opts,
12781302
)
12791303
return "\n\n".join(pages)
12801304
finally:
@@ -1441,6 +1465,8 @@ async def extract_batch(
14411465
ocr_backend: str = "none",
14421466
llm_backend: str | None = None,
14431467
parse_fn=None,
1468+
parser_options: dict | None = None,
1469+
ocr_options: dict | None = None,
14441470
) -> list[dict]:
14451471
"""Extract from multiple PDFs concurrently.
14461472
@@ -1460,11 +1486,14 @@ async def extract_batch(
14601486
parse_fn: Optional async callable(pdf_path, parser,
14611487
ocr_backend) -> str. When provided, replaces local
14621488
text extraction.
1489+
parser_options: Extra kwargs passed to the parser callable.
1490+
ocr_options: Extra kwargs passed to the OCR callable.
14631491
14641492
Returns:
14651493
List of result dicts (with _source_file and optionally
14661494
_error).
14671495
"""
1496+
_p_opts = parser_options or {}
14681497
mgr = get_manager()
14691498
mgr.configure(api_limit=concurrency)
14701499
client = _make_client(model, api_key, llm_backend)
@@ -1484,13 +1513,14 @@ async def _process(path: str):
14841513
)
14851514
elif parser_is_async:
14861515
pages = await mgr.run(
1487-
parser_fn, path,
1516+
parser_fn, path, **_p_opts,
14881517
)
14891518
text = "\n\n".join(pages)
14901519
else:
14911520
text = await mgr.run_cpu(
14921521
extract_text, path, parser,
14931522
ocr_fallback, ocr_backend,
1523+
_p_opts, ocr_options,
14941524
)
14951525
async with mgr.api():
14961526
result = (

tests/test_extract.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,3 +1948,45 @@ def test_bad_attr_raises_at_build(self):
19481948
from petey.extract import _make_plugin_loader
19491949
with pytest.raises(AttributeError):
19501950
_make_plugin_loader("json:nonexistent_function_xyz")
1951+
1952+
1953+
# ---------------------------------------------------------------------------
1954+
# parser_options / ocr_options passthrough
1955+
# ---------------------------------------------------------------------------
1956+
1957+
class TestBackendOptions:
1958+
"""Verify parser_options and ocr_options reach the callable."""
1959+
1960+
def test_parser_options_passed(self):
1961+
mock_fn = MagicMock(return_value=["page text"])
1962+
with patch.dict(
1963+
"petey.extract.PARSERS", {"mock": mock_fn},
1964+
):
1965+
extract_text_pages(
1966+
str(MCI_PDF), parser="mock",
1967+
parser_options={"lang": "fr", "dpi": 300},
1968+
)
1969+
mock_fn.assert_called_once()
1970+
_, kwargs = mock_fn.call_args
1971+
assert kwargs["lang"] == "fr"
1972+
assert kwargs["dpi"] == 300
1973+
1974+
def test_ocr_options_passed(self):
1975+
from petey.extract import OCR_THRESHOLD
1976+
mock_parser = MagicMock(return_value=["x"])
1977+
mock_ocr = MagicMock(return_value="ocr text")
1978+
with patch.dict(
1979+
"petey.extract.PARSERS", {"mock": mock_parser},
1980+
):
1981+
with patch.dict(
1982+
"petey.extract.OCR_BACKENDS",
1983+
{"mock_ocr": mock_ocr},
1984+
):
1985+
extract_text_pages(
1986+
str(MCI_PDF), parser="mock",
1987+
ocr_backend="mock_ocr",
1988+
ocr_options={"languages": ["en", "fr"]},
1989+
)
1990+
mock_ocr.assert_called_once()
1991+
_, kwargs = mock_ocr.call_args
1992+
assert kwargs["languages"] == ["en", "fr"]

0 commit comments

Comments
 (0)