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
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ All notable changes to this SDK are documented here. Format follows

### Added

- `review_file` and `review_file_and_wait` (on `Lenz` and `AsyncLenz`): upload a `.docx` or `.pdf` document (`POST /api/v1/review/file`) from a file path (`str` / `os.PathLike`), `bytes`, or binary file object (`BinaryIO`), with the same review escalation options as `review`. `ReviewStarted.kept` (`bool | None`) reports whether the uploaded `.docx` was retained so an annotated `.docx` export preserves the original document's formatting.
- `review_file` and `review_file_and_wait` (on `Lenz` and `AsyncLenz`): upload a `.docx`, `.pdf`, or standalone image (`.png`, `.jpg`, `.jpeg`, `.webp`) file (`POST /api/v1/review/file`) from a file path (`str` / `os.PathLike`), `bytes`, or binary file object (`BinaryIO`), with the same review escalation options as `review`. `ReviewStarted.kept` (`bool | None`) reports whether the uploaded `.docx` was retained so an annotated `.docx` export preserves the original document's formatting (`False` for PDFs and images). `extract` and `review` URL inputs also accept public Google Docs and direct image links (`.png`, `.jpg`, `.webp`).
- `export_review(review_id, *, format="docx") -> ReviewExport` (on `Lenz` and `AsyncLenz`): export an annotated document (`GET /api/v1/reviews/{review_id}/export?format=docx`) with Lenz's findings as Word comments and suggested edits as tracked changes. Returns `ReviewExport` (a `Result`, exported from `lenz_io`, with `.data`, `.format`, `.filename`, `.source`, and `.content_type`).
- `lenz review <draft.docx|draft.pdf>` and `--export <path>`: the CLI uploads `.docx` and `.pdf` files directly via `review_file`, and `--export` writes the annotated `.docx` when the review finishes (validating the output extension before submitting and warning without failing when a clean draft returns `409 nothing_to_mark`).
- Upload and export error codes (`unsupported_file_type`, `invalid_file`, `encrypted_file`, `empty_file`, `no_readable_text`, `file_too_large`, `text_too_long`, `upload_limit`, `unsupported_format`, `nothing_to_mark`, `not_exportable`, `export_limit`, `export_failed`) mapped to their corresponding `LenzAPIError` subclasses and hints.
- `lenz review <draft.docx|draft.pdf|draft.png|draft.jpg|draft.webp>` and `--export <path>`: the CLI uploads `.docx`, `.pdf`, and image files directly via `review_file`, and `--export` writes the annotated `.docx` when the review finishes (validating the output extension before submitting and warning without failing when a clean draft returns `409 nothing_to_mark`).
- Upload and export error codes (`unsupported_file_type`, `invalid_file`, `encrypted_file`, `empty_file`, `no_readable_text`, `image_animated`, `file_too_large`, `text_too_long`, `upload_limit`, `unsupported_format`, `nothing_to_mark`, `not_exportable`, `export_limit`, `export_failed`) mapped to their corresponding `LenzAPIError` subclasses and hints.

## [3.4.0] - 2026-10-13

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,12 @@ read it with `client.get_review(review_id)`, only the issues with
`client.get_review(review_id, view="issues")`, or poll it with
`client.wait_for_review(review_id)`.

**Reviewing document files (.docx, .pdf).** Instead of raw text, you can upload Word documents or PDFs directly using `review_file` and `review_file_and_wait`. Pass a file path, `pathlib.Path`, open binary reader, or raw `bytes`:
**Reviewing document and image files (.docx, .pdf, .png, .jpg, .webp).** Instead of raw text, you can upload Word documents, PDFs, or images directly using `review_file` and `review_file_and_wait`. URL inputs also accept Google Docs and direct image links alongside web articles. Pass a file path, `pathlib.Path`, open binary reader, or raw `bytes`:

```python
# Submit and wait for a Word document or PDF:
# Submit and wait for a Word document, PDF, or image:
review = client.review_file_and_wait("report.docx")
review_img = client.review_file_and_wait("infographic.png")

# Or submit without waiting and poll later:
started = client.review_file("report.pdf")
Expand Down Expand Up @@ -301,7 +302,7 @@ lenz status <task_id> # non-blocking: poll a verify task's progress
lenz show <verification_id> # full report — sources, warnings, panel + debate (-c for concise)
lenz ask <verification_id> "Which source is strongest?"
lenz review draft.md # the whole draft: quick verdicts, deep checks, and up to 20 of its sources
lenz review report.docx # review Word document or PDF directly
lenz review report.docx # review Word document, PDF, or image (.png, .jpg, .webp) directly
lenz review report.docx --export annotated.docx # save annotated Word document with comments
lenz review draft.md --issues # only the issues
lenz review draft.md --max-citations 0 # claims only, no source checked
Expand Down
17 changes: 13 additions & 4 deletions src/lenz_io/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ def _prepare_review_file_multipart(
max_verifications: int | None = None,
depth: str | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
"""Validate file input and construct httpx files and data dictionaries."""
"""Validate file input and construct httpx files and data dictionaries.

Accepts Word (.docx), PDF (.pdf), and image (.png, .jpg, .webp) files.
"""
# 1. Resolve file content, filename, content_type
file_bytes: bytes
resolved_filename: str
Expand Down Expand Up @@ -141,18 +144,24 @@ def _prepare_review_file_multipart(
param="file",
)

# Validate filename extension: only .docx and .pdf are supported by POST /review/file
# Validate filename extension: .docx, .pdf, .png, .jpg, .jpeg, and .webp are supported by POST /review/file
lower_fn = resolved_filename.lower()
if lower_fn.endswith(".docx"):
resolved_content_type = (
content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
elif lower_fn.endswith(".pdf"):
resolved_content_type = content_type or "application/pdf"
elif lower_fn.endswith(".png"):
resolved_content_type = content_type or "image/png"
elif lower_fn.endswith(".jpg") or lower_fn.endswith(".jpeg"):
resolved_content_type = content_type or "image/jpeg"
elif lower_fn.endswith(".webp"):
resolved_content_type = content_type or "image/webp"
else:
raise LenzUsageError(
f"Unsupported file format for review: {resolved_filename}. Only Word (.docx) and PDF (.pdf) "
"files are supported.",
f"Unsupported file format for review: {resolved_filename}. Only Word (.docx), PDF (.pdf), "
"and image (.png, .jpg, .webp) files are supported.",
code="invalid_argument",
param="file",
)
Expand Down
4 changes: 2 additions & 2 deletions src/lenz_io/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1888,7 +1888,7 @@ async def review_file(
extra_headers: Mapping[str, str | None] | None = None,
idempotency: bool = True,
) -> ReviewStarted:
"""Submit a Word (.docx) or PDF (.pdf) file
"""Submit a Word (.docx), PDF (.pdf), or image (.png, .jpg, .webp) file
for review via multipart/form-data.

Returns a ``ReviewStarted`` receipt with a ``review_id``.
Expand Down Expand Up @@ -1954,7 +1954,7 @@ async def review_file_and_wait(
idempotency: bool = True,
cancel_on_abort: bool = False,
) -> ReviewFull:
"""Submit a Word (.docx) or PDF (.pdf) file
"""Submit a Word (.docx), PDF (.pdf), or image (.png, .jpg, .webp) file
for review and poll until it ends.

Returns the completed ``ReviewFull``.
Expand Down
17 changes: 11 additions & 6 deletions src/lenz_io/cli/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@

def review(
ctx: typer.Context,
draft: str = typer.Argument(None, help="The draft: a file path, '-' (or a pipe) for stdin, or one http(s) URL."),
draft: str = typer.Argument(
None,
help=(
"The draft: a file path (.docx, .pdf, .png, .jpg, .webp, or text), '-' (or a pipe) for stdin, "
"or one http(s) URL (web articles, Google Docs, or direct image URLs)."
),
),
issues: bool = typer.Option(False, "--issues", help="Print only the issues (and any failed rows)."),
max_assessments: int = typer.Option(
None,
Expand Down Expand Up @@ -285,17 +291,16 @@ def _parse_depth(raw: str | None) -> str | None:


def _is_binary_document(arg: str | None) -> bool:
"""Check if arg points to a binary document (.docx or .pdf)."""
"""Check if arg points to a binary document or image (.docx, .pdf, .png, .jpg, .jpeg, .webp)."""
if not arg or arg == "-" or _URL.match(arg.strip()):
return False
lower = arg.lower()
return lower.endswith(".docx") or lower.endswith(".pdf")
return arg.lower().endswith((".docx", ".pdf", ".png", ".jpg", ".jpeg", ".webp"))


def _resolve_draft_input(arg: str | None) -> tuple[str | None, Path | None]:
"""Resolve draft argument into (text, file_path).

For binary documents (.docx, .pdf), returns (None, path).
For binary documents and images (.docx, .pdf, .png, .jpg, .jpeg, .webp), returns (None, path).
For text files, URLs, or stdin, returns (text, None).
"""
if arg is not None and _is_binary_document(arg):
Expand All @@ -311,7 +316,7 @@ def _resolve_draft_input(arg: str | None) -> tuple[str | None, Path | None]:
raise CLIError(
f"File is empty: {arg}",
code="no_input",
fix="Pass a non-empty .docx or .pdf document.",
fix="Pass a non-empty document or image file (.docx, .pdf, .png, .jpg, .webp).",
exit_code=2,
)
return None, path
Expand Down
4 changes: 2 additions & 2 deletions src/lenz_io/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1867,7 +1867,7 @@ def review_file(
extra_headers: Mapping[str, str | None] | None = None,
idempotency: bool = True,
) -> ReviewStarted:
"""Submit a Word (.docx) or PDF (.pdf) file
"""Submit a Word (.docx), PDF (.pdf), or image (.png, .jpg, .webp) file
for review via multipart/form-data.

Returns a ``ReviewStarted`` receipt with a ``review_id``.
Expand Down Expand Up @@ -1932,7 +1932,7 @@ def review_file_and_wait(
extra_headers: Mapping[str, str | None] | None = None,
idempotency: bool = True,
) -> ReviewFull:
"""Submit a Word (.docx) or PDF (.pdf) file
"""Submit a Word (.docx), PDF (.pdf), or image (.png, .jpg, .webp) file
for review and poll until it ends.

Returns the completed ``ReviewFull``.
Expand Down
4 changes: 2 additions & 2 deletions src/lenz_io/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,8 +954,8 @@ def _parse_body(raw: bytes | str | None) -> dict[str, Any]:
# (the same rules as the Node SDK), so every attribute of the exception keeps
# its 2.x value; ``exc.body`` is the body as sent.

#: /review and /citecheck (submit and read) kept their own error envelope.
_REVIEW_FAMILY = re.compile(r"^/(?:review|reviews/[^/]+|citecheck|citechecks/[^/]+)$")
#: /review, /review/file, and /citecheck (submit and read) kept their own error envelope.
_REVIEW_FAMILY = re.compile(r"^/(?:review|review/file|reviews/[^/]+|citecheck|citechecks/[^/]+)$")

#: The cancel calls are new in 3.0: no earlier shape to read, so the server's
#: ``code`` stays on the error (``not_found``, ``use_review_cancel``, ...).
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,29 @@ def test_review_binary_pdf_file(tmp_path):
assert b'filename="memo.pdf"' in content


@pytest.mark.parametrize(
"ext,mime",
[(".png", "image/png"), (".jpg", "image/jpeg"), (".jpeg", "image/jpeg"), (".webp", "image/webp")],
)
def test_review_binary_image_files(tmp_path, ext, mime):
img_file = tmp_path / f"chart{ext}"
img_file.write_bytes(b"fake image data")

with respx.mock(base_url=BASE) as r:
post = r.post("/review/file").respond(
202,
json={"status": "queued", "review_id": "442b6aa9"},
)
r.get(f"{BASE}/reviews/442b6aa9").respond(200, json=_load("review_completed.json"))

res = _invoke("review", str(img_file))
assert res.exit_code == 1
assert post.called
content = post.calls.last.request.content
assert f'filename="chart{ext}"'.encode() in content
assert f"Content-Type: {mime}".encode() in content


def test_review_export_download_option(tmp_path):
docx_file = tmp_path / "report.docx"
docx_file.write_bytes(b"PK\x03\x04fake docx content")
Expand Down Expand Up @@ -1027,6 +1050,7 @@ def test_review_empty_binary_file(tmp_path):
res = _invoke("review", str(empty_file))
assert res.exit_code == 2
assert "File is empty:" in res.output
assert "Pass a non-empty document or image file (.docx, .pdf, .png, .jpg, .webp)." in res.output


def test_review_export_nothing_to_mark(tmp_path):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,3 +684,19 @@ def test_the_new_classes_are_exported():
for name in ("LenzNotFoundError", "LenzConnectionError", "LenzRequestTimeoutError"):
assert name in lenz_io.__all__
assert issubclass(getattr(lenz_io, name), LenzError)


def test_review_family_regex_matches_review_file():
from lenz_io.errors import _REVIEW_FAMILY, _original_error

assert _REVIEW_FAMILY.match("/review/file")
assert _REVIEW_FAMILY.match("/review")
assert _REVIEW_FAMILY.match("/reviews/442b6aa9")
assert _REVIEW_FAMILY.match("/citecheck")
assert _REVIEW_FAMILY.match("/citechecks/442b6aa9")
assert not _REVIEW_FAMILY.match("/verify")

# When /review/file encounters not_authenticated, code is stripped per _REVIEW_FAMILY handling
original = _original_error(401, {"code": "not_authenticated", "detail": "bad"}, "POST", "/review/file")
assert "code" not in original
assert original["detail"] == "bad"
31 changes: 29 additions & 2 deletions tests/test_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,27 @@ def test_review_file_bytes_and_stream(self, client):
started2 = client.review_file(stream)
assert started2.review_id == "rev_pdf_456"

def test_review_file_image_formats_and_mime_types(self, client):
test_cases = [
("photo.png", b"\x89PNG\r\n\x1a\nfake", "image/png"),
("photo.jpg", b"\xff\xd8\xfffake", "image/jpeg"),
("photo.jpeg", b"\xff\xd8\xfffake", "image/jpeg"),
("photo.webp", b"RIFF\x00\x00\x00\x00WEBPfake", "image/webp"),
]
for fname, data, expected_mime in test_cases:
with respx.mock(base_url="https://lenz.io/api/v1") as respx_mock:
route = respx_mock.post("/review/file").respond(
202,
json={"status": "queued", "review_id": f"rev_{fname}"},
)
started = client.review_file(data, filename=fname)
assert started.review_id == f"rev_{fname}"
assert route.called
req = route.calls.last.request
content = req.content
assert fname.encode() in content
assert f"Content-Type: {expected_mime}".encode() in content

def test_review_file_validation_errors(self, client, tmp_path: Path):
from lenz_io.errors import LenzUsageError

Expand All @@ -1178,11 +1199,17 @@ def test_review_file_validation_errors(self, client, tmp_path: Path):
# Unsupported format (e.g. .xyz or .txt)
with pytest.raises(LenzUsageError) as exc_info:
client.review_file(b"some bytes", filename="unsupported.xyz")
assert "Unsupported file format" in str(exc_info.value)
assert (
"Unsupported file format for review: unsupported.xyz. Only Word (.docx), PDF (.pdf), "
"and image (.png, .jpg, .webp) files are supported." == str(exc_info.value)
)

with pytest.raises(LenzUsageError) as exc_info:
client.review_file(b"some bytes", filename="notes.txt")
assert "Unsupported file format" in str(exc_info.value)
assert (
"Unsupported file format for review: notes.txt. Only Word (.docx), PDF (.pdf), "
"and image (.png, .jpg, .webp) files are supported." == str(exc_info.value)
)

# Missing file path
with pytest.raises(LenzUsageError) as exc_info:
Expand Down
Loading