From 8e8e3d80bd51d2336dcf1cd1aaed0610b300e050 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 19 Oct 2026 19:08:58 +0530 Subject: [PATCH 1/5] feat: update document fields and line items --- src/photon/client.py | 119 ++++++++++++++++++++++++++- tests/test_update.py | 188 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 tests/test_update.py diff --git a/src/photon/client.py b/src/photon/client.py index 9e6c617..f0a4ef4 100644 --- a/src/photon/client.py +++ b/src/photon/client.py @@ -11,6 +11,9 @@ import contextlib import os import re +from collections.abc import Mapping +from datetime import date +from decimal import Decimal from types import TracebackType from typing import IO, TYPE_CHECKING, Any, cast, overload @@ -25,10 +28,12 @@ DEFAULT_TIMEOUT, RETRIEVE_PATH, SUBMIT_PATH, + UPDATE_LINE_ITEM_PATH, + UPDATE_PATH, DocType, Environment, ) -from .exceptions import APIError +from .exceptions import APIError, InvalidRequestError from .models import BaseDocument, Submission, document_for if TYPE_CHECKING: @@ -54,6 +59,9 @@ _SUBACCOUNT_MAX_LEN = 50 _SUBACCOUNT_RE = re.compile(r"[A-Za-z0-9-]+") +# Line item IDs are the API's own numbering: ASCII digits only. +_LINE_ITEM_ID_RE = re.compile(r"[0-9]+") + class PhotonClient: """Synchronous client for the Photon Commerce API. @@ -239,8 +247,7 @@ def retrieve( PhotonError: See :meth:`Transport.request_json` for the rest of the mapping. """ - if not photon_key or not photon_key.strip(): - raise ValueError("photon_key must be a non-empty string.") + _check_photon_key(photon_key) body = self._transport.request_json("GET", RETRIEVE_PATH, params={"photon_key": photon_key}) @@ -340,6 +347,64 @@ def extract( interval=poll_interval, ) + def update_fields(self, photon_key: str, fields: Mapping[str, Any]) -> None: + """Correct extracted fields on a processed document. + + Keys are the **API's own field names**, exactly as the result spells + them — ``{"Vendor_Name": ..., "Total": ...}`` for an invoice. They are + sent as given: the SDK does not translate attribute names such as + ``vendor_name``, because the same word means different fields in + different doctype families (``total`` is a check's field, ``Total`` an + invoice's). :class:`~decimal.Decimal` and :class:`~datetime.date` + values are sent as strings (``"1397.50"``, ``"2026-10-19"``). + + Args: + photon_key: The document's key, from :attr:`Submission.photon_key`. + fields: The fields to change, by API field name. + + Raises: + ValueError: ``photon_key`` or ``fields`` is empty, or a field name + is not a non-empty string — checked before any I/O. + InvalidRequestError: The API rejected the update. + PhotonError: See :meth:`Transport.request_json` for the rest of + the mapping. + """ + _check_photon_key(photon_key) + body = _json_fields(fields) + response = self._transport.request_json( + "PUT", UPDATE_PATH, params={"photon_key": photon_key}, json=body + ) + _expect_success(response) + + def update_line_item( + self, photon_key: str, line_item_id: int | str, fields: Mapping[str, Any] + ) -> None: + """Correct fields on one of a document's line items. + + Args: + photon_key: The document's key. + line_item_id: The line's ID — its ``Line`` number on an invoice. + fields: The line fields to change, by API field name, e.g. + ``{"QTY": 12, "Amount": "1380.00"}``. Sent as for + :meth:`update_fields`. + + Raises: + ValueError: An empty ``photon_key`` or ``fields``, or a + ``line_item_id`` that is not a whole number — checked before + any I/O. + InvalidRequestError: The API rejected the update, e.g. no line has + that ID. + PhotonError: See :meth:`Transport.request_json` for the rest of + the mapping. + """ + _check_photon_key(photon_key) + path = _line_item_path(line_item_id) + body = _json_fields(fields) + response = self._transport.request_json( + "PUT", path, params={"photon_key": photon_key}, json=body + ) + _expect_success(response) + def close(self) -> None: """Close the underlying connection pool. Safe to call more than once.""" self._transport.close() @@ -383,3 +448,51 @@ def _filename_for(document: IO[bytes]) -> str: def _is_valid_subaccount(subaccount: str) -> bool: # fullmatch, not match: with match, "$" would accept a trailing newline. return len(subaccount) <= _SUBACCOUNT_MAX_LEN and bool(_SUBACCOUNT_RE.fullmatch(subaccount)) + + +def _check_photon_key(photon_key: str) -> None: + if not isinstance(photon_key, str) or not photon_key.strip(): + raise ValueError("photon_key must be a non-empty string.") + + +def _line_item_path(line_item_id: int | str) -> str: + """The update/delete path for a line item, after checking its ID.""" + # bool is an int subclass; True is a mistake, not line 1. + valid_type = isinstance(line_item_id, (int, str)) and not isinstance(line_item_id, bool) + text = str(line_item_id).strip() if valid_type else "" + if not _LINE_ITEM_ID_RE.fullmatch(text): + raise ValueError(f"line_item_id must be a whole number, got {line_item_id!r}.") + return f"{UPDATE_LINE_ITEM_PATH}/{text}" + + +def _json_fields(fields: Mapping[str, Any]) -> dict[str, Any]: + """Check an update's fields and make their values JSON-serialisable.""" + if not isinstance(fields, Mapping) or not fields: + raise ValueError("fields must be a non-empty mapping of API field names to values.") + body: dict[str, Any] = {} + for name, value in fields.items(): + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"Field names must be non-empty strings, got {name!r}.") + body[name] = _json_value(value) + return body + + +def _json_value(value: Any) -> Any: + # The models parse amounts to Decimal and dates to date; accept them back. + if isinstance(value, Decimal): + return str(value) + if isinstance(value, date): + return value.isoformat() + return value + + +def _expect_success(body: dict[str, Any]) -> None: + """Raise if a 2xx response's body still reports a failure.""" + status = body.get("status") + if status is None or (isinstance(status, str) and status.lower() == "success"): + return + message = body.get("message") + raise InvalidRequestError( + message if isinstance(message, str) and message else f"Request failed: {status!r}", + body=body, + ) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..06f59f2 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,188 @@ +"""Tests for correcting extracted data: update_fields and update_line_item. + +The API is stubbed with respx; the success and error bodies are the ones the +official API docs (apidocs.photoncommerce.com) show for these endpoints. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from datetime import date +from decimal import Decimal +from typing import Any + +import httpx +import pytest +import respx + +from photon import InvalidRequestError, PhotonClient +from photon.constants import UPDATE_LINE_ITEM_PATH, UPDATE_PATH + +BASE_URL = "https://sandbox-api.photoncommerce.com" +KEY = "data/user@example.com/2026-10-19/10-00-00_invoice.json" + + +def make_client(**overrides: Any) -> PhotonClient: + values: dict[str, Any] = { + "client_id": "AAA111", + "username": "user@example.com", + "api_key": "BBB222", + "password": "CCC333", + "secret_key": "DDD444", + } + values.update(overrides) + return PhotonClient(**values) + + +@pytest.fixture +def client() -> Iterator[PhotonClient]: + with make_client(max_retries=0) as open_client: + yield open_client + + +def success(message: str) -> httpx.Response: + return httpx.Response(200, json={"message": message, "status": "success"}) + + +# --------------------------------------------------------------------------- # +# update_fields +# --------------------------------------------------------------------------- # + + +def test_update_fields_puts_the_fields_with_the_key(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.put(UPDATE_PATH).mock(return_value=success("Updated successfully")) + client.update_fields(KEY, {"Vendor_Name": "Acme Ltd", "Total": "99.00"}) + + request = route.calls.last.request + assert request.url.params["photon_key"] == KEY + assert json.loads(request.content) == {"Vendor_Name": "Acme Ltd", "Total": "99.00"} + assert request.headers["content-type"] == "application/json" + + +def test_update_fields_sends_parsed_values_back_as_strings(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.put(UPDATE_PATH).mock(return_value=success("Updated successfully")) + client.update_fields( + KEY, {"Total": Decimal("1397.50"), "Due_Date": date(2026, 11, 18), "Pages": 1} + ) + + assert json.loads(route.calls.last.request.content) == { + "Total": "1397.50", + "Due_Date": "2026-11-18", + "Pages": 1, + } + + +def test_update_fields_sends_field_names_verbatim(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.put(UPDATE_PATH).mock(return_value=success("Updated successfully")) + client.update_fields(KEY, {"vendor_name": "x", "payerName": "y"}) + + assert json.loads(route.calls.last.request.content) == {"vendor_name": "x", "payerName": "y"} + + +@pytest.mark.parametrize("fields", [{}, None, [("Total", "1")], "Total=1"]) +def test_update_fields_rejects_empty_or_non_mapping_fields( + client: PhotonClient, fields: Any +) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="fields"): + client.update_fields(KEY, fields) + assert not mock.calls + + +@pytest.mark.parametrize("fields", [{"": "x"}, {" ": "x"}, {1: "x"}]) +def test_update_fields_rejects_bad_field_names(client: PhotonClient, fields: Any) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="Field names"): + client.update_fields(KEY, fields) + assert not mock.calls + + +@pytest.mark.parametrize("key", ["", " "]) +def test_update_fields_rejects_an_empty_key(client: PhotonClient, key: str) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="photon_key"): + client.update_fields(key, {"Total": "1"}) + assert not mock.calls + + +@pytest.mark.parametrize( + "message", ["Data field missing!", "Photon Key missing", "Photon Key Invalid"] +) +def test_update_fields_maps_api_rejections(client: PhotonClient, message: str) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.put(UPDATE_PATH).mock( + return_value=httpx.Response(403, json={"message": message, "status": "error"}) + ) + with pytest.raises(InvalidRequestError, match=message): + client.update_fields(KEY, {"Total": "1"}) + + +def test_a_2xx_reporting_failure_still_raises(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.put(UPDATE_PATH).mock( + return_value=httpx.Response(200, json={"message": "Nothing updated", "status": "error"}) + ) + with pytest.raises(InvalidRequestError, match="Nothing updated") as caught: + client.update_fields(KEY, {"Total": "1"}) + + assert caught.value.body == {"message": "Nothing updated", "status": "error"} + + +def test_a_2xx_failure_without_a_message_names_the_status(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.put(UPDATE_PATH).mock(return_value=httpx.Response(200, json={"status": "failed"})) + with pytest.raises(InvalidRequestError, match="failed"): + client.update_fields(KEY, {"Total": "1"}) + + +def test_a_success_body_without_a_status_is_accepted(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.put(UPDATE_PATH).mock( + return_value=httpx.Response(200, json={"message": "Updated successfully"}) + ) + client.update_fields(KEY, {"Total": "1"}) + + +# --------------------------------------------------------------------------- # +# update_line_item +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("line_item_id", [3, "3", " 3 "]) +def test_update_line_item_puts_to_the_items_path( + client: PhotonClient, line_item_id: int | str +) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.put(f"{UPDATE_LINE_ITEM_PATH}/3").mock( + return_value=success("Updated line item successfully") + ) + client.update_line_item(KEY, line_item_id, {"QTY": 12, "Amount": Decimal("1380.00")}) + + request = route.calls.last.request + assert request.url.params["photon_key"] == KEY + assert json.loads(request.content) == {"QTY": 12, "Amount": "1380.00"} + + +@pytest.mark.parametrize("line_item_id", ["abc", "", "-1", "1.5", "3/../4", "٣", True, 1.0, None]) +def test_update_line_item_rejects_a_non_numeric_id(client: PhotonClient, line_item_id: Any) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="line_item_id"): + client.update_line_item(KEY, line_item_id, {"QTY": 1}) + assert not mock.calls + + +def test_update_line_item_rejects_empty_fields(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="fields"): + client.update_line_item(KEY, 1, {}) + assert not mock.calls + + +def test_update_line_item_maps_an_invalid_id(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.put(f"{UPDATE_LINE_ITEM_PATH}/99").mock( + return_value=httpx.Response( + 403, json={"message": "Line Item ID invalid", "status": "error"} + ) + ) + with pytest.raises(InvalidRequestError, match="Line Item ID invalid"): + client.update_line_item(KEY, 99, {"QTY": 1}) From 0ebf8875068be45ccef2bd45275ca4634b1be6bf Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 19 Oct 2026 19:09:59 +0530 Subject: [PATCH 2/5] feat: add and delete line items --- src/photon/_transport.py | 34 +++++++-- src/photon/client.py | 61 +++++++++++++++ tests/test_line_items.py | 156 +++++++++++++++++++++++++++++++++++++++ tests/test_retries.py | 17 +++++ 4 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 tests/test_line_items.py diff --git a/src/photon/_transport.py b/src/photon/_transport.py index 93370e2..476377c 100644 --- a/src/photon/_transport.py +++ b/src/photon/_transport.py @@ -120,6 +120,7 @@ def request( data: dict[str, Any] | None = None, files: Any = None, headers: dict[str, str] | None = None, + idempotent: bool | None = None, ) -> httpx.Response: """Send a request and return the successful response. @@ -134,6 +135,9 @@ def request( data: Form fields for a multipart or form-encoded body. files: Files for a multipart body, in httpx's format. headers: Extra headers, merged over the client's defaults. + idempotent: Whether repeating the request is harmless, which + decides how it is retried. ``None`` infers it from the method; + pass ``False`` for a PUT or DELETE that is not safe to repeat. Returns: The response, which is guaranteed to have a 2xx status. @@ -147,7 +151,14 @@ def request( PhotonConnectionError: The request never reached the API. """ response, _ = self._request( - method, path, params=params, json=json, data=data, files=files, headers=headers + method, + path, + params=params, + json=json, + data=data, + files=files, + headers=headers, + idempotent=idempotent, ) return response @@ -161,6 +172,7 @@ def request_json( data: dict[str, Any] | None = None, files: Any = None, headers: dict[str, str] | None = None, + idempotent: bool | None = None, ) -> dict[str, Any]: """Send a request and return the decoded JSON object. @@ -168,7 +180,14 @@ def request_json( plus an :class:`APIError` if the response body is not a JSON object. """ response, body = self._request( - method, path, params=params, json=json, data=data, files=files, headers=headers + method, + path, + params=params, + json=json, + data=data, + files=files, + headers=headers, + idempotent=idempotent, ) if not isinstance(body, dict): raise APIError( @@ -211,6 +230,7 @@ def _request( data: dict[str, Any] | None = None, files: Any = None, headers: dict[str, str] | None = None, + idempotent: bool | None = None, ) -> tuple[httpx.Response, Any]: """Send one request, classify the result, and return it with its body.""" response = self._send( @@ -221,6 +241,7 @@ def _request( data=data, files=files, headers=headers, + idempotent=method.upper() in _IDEMPOTENT_METHODS if idempotent is None else idempotent, ) body = _decode_body(response) _raise_for_response(response, body) @@ -236,6 +257,7 @@ def _send( data: dict[str, Any] | None, files: Any, headers: dict[str, str] | None, + idempotent: bool, ) -> httpx.Response: """Perform the network round trip, retrying transient failures. @@ -258,14 +280,14 @@ def _send( headers=headers, ) except httpx.RequestError as exc: - if not self._should_retry(method, retries, exc): + if not self._should_retry(idempotent, retries, exc): attempts = f" after {retries + 1} attempts" if retries else "" raise PhotonConnectionError(f"{method} {path} failed{attempts}: {exc}") from exc # The exception's type, not its text: the text can quote the URL, # whose query string may carry a photon_key. reason = type(exc).__name__ else: - if not response.is_server_error or not self._should_retry(method, retries): + if not response.is_server_error or not self._should_retry(idempotent, retries): return response reason = f"HTTP {response.status_code}" @@ -285,12 +307,12 @@ def _send( self._sleep(delay) def _should_retry( - self, method: str, retries: int, error: httpx.RequestError | None = None + self, idempotent: bool, retries: int, error: httpx.RequestError | None = None ) -> bool: """Whether a failed attempt may be repeated; ``error`` is ``None`` for a 5xx.""" if retries >= self.config.max_retries: return False - if method.upper() in _IDEMPOTENT_METHODS: + if idempotent: return True return isinstance(error, _NOT_SENT_ERRORS) diff --git a/src/photon/client.py b/src/photon/client.py index f0a4ef4..aa48893 100644 --- a/src/photon/client.py +++ b/src/photon/client.py @@ -21,6 +21,7 @@ from ._transport import Transport from .config import Config from .constants import ( + ADD_LINE_ITEM_PATH, DEFAULT_BACKOFF_FACTOR, DEFAULT_MAX_RETRIES, DEFAULT_POLL_INTERVAL, @@ -405,6 +406,66 @@ def update_line_item( ) _expect_success(response) + def add_line_item( + self, photon_key: str, fields: Mapping[str, Any], *, url: str | None = None + ) -> None: + """Add a line item to an invoice or receipt. + + This is the invoice-family endpoint. Checks and remittances add lines + through a different one, which this release does not wrap. + + Not retried after a server error or a lost response: the line may + already have been added, and a second request would add it twice. + + Args: + photon_key: The document's key. + fields: The new line, by API field name, e.g. + ``{"Line": 3, "SKU": "WID-300", "Description": "Widget", + "QTY": 1, "Price": "10.00", "Amount": "10.00"}``. Sent as for + :meth:`update_fields`. + url: The document's URL, for documents submitted by URL. + + Raises: + ValueError: An empty ``photon_key`` or ``fields`` — checked before + any I/O. + InvalidRequestError: The API rejected the line, e.g. one with that + ``Line`` number already exists. + PhotonError: See :meth:`Transport.request_json` for the rest of + the mapping. + """ + _check_photon_key(photon_key) + body = _json_fields(fields) + response = self._transport.request_json( + "POST", ADD_LINE_ITEM_PATH, params={"photon_key": photon_key, "url": url}, json=body + ) + _expect_success(response) + + def delete_line_item(self, photon_key: str, line_item_id: int | str) -> None: + """Delete one of a document's line items. + + Not retried after a server error or a lost response. The first request + may have succeeded, and if the API renumbers the remaining lines, a + repeat would delete a different one. + + Args: + photon_key: The document's key. + line_item_id: The line's ID — its ``Line`` number on an invoice. + + Raises: + ValueError: An empty ``photon_key``, or a ``line_item_id`` that is + not a whole number — checked before any I/O. + InvalidRequestError: The API rejected the delete, e.g. no line has + that ID. + PhotonError: See :meth:`Transport.request_json` for the rest of + the mapping. + """ + _check_photon_key(photon_key) + path = _line_item_path(line_item_id) + response = self._transport.request_json( + "DELETE", path, params={"photon_key": photon_key}, idempotent=False + ) + _expect_success(response) + def close(self) -> None: """Close the underlying connection pool. Safe to call more than once.""" self._transport.close() diff --git a/tests/test_line_items.py b/tests/test_line_items.py new file mode 100644 index 0000000..0aa1f91 --- /dev/null +++ b/tests/test_line_items.py @@ -0,0 +1,156 @@ +"""Tests for add_line_item and delete_line_item. + +The API is stubbed with respx; bodies follow the official API docs +(apidocs.photoncommerce.com). +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from decimal import Decimal +from typing import Any + +import httpx +import pytest +import respx + +from photon import APIError, InvalidRequestError, PhotonClient +from photon.constants import ADD_LINE_ITEM_PATH, UPDATE_LINE_ITEM_PATH + +BASE_URL = "https://sandbox-api.photoncommerce.com" +KEY = "data/user@example.com/2026-10-19/10-00-00_invoice.json" +LINE = {"Line": 3, "SKU": "WID-300", "Description": "Widget", "Price": "10.00", "Amount": "10.00"} + + +def make_client(**overrides: Any) -> PhotonClient: + values: dict[str, Any] = { + "client_id": "AAA111", + "username": "user@example.com", + "api_key": "BBB222", + "password": "CCC333", + "secret_key": "DDD444", + } + values.update(overrides) + return PhotonClient(**values) + + +@pytest.fixture +def client() -> Iterator[PhotonClient]: + # backoff_factor=0: a retry, if one happened, would not slow the suite. + with make_client(backoff_factor=0) as open_client: + yield open_client + + +def success(message: str) -> httpx.Response: + return httpx.Response(200, json={"message": message, "status": "success"}) + + +# --------------------------------------------------------------------------- # +# add_line_item +# --------------------------------------------------------------------------- # + + +def test_add_line_item_posts_to_the_invoice_path(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.post(ADD_LINE_ITEM_PATH).mock( + return_value=success("Added line item successfully") + ) + client.add_line_item(KEY, {**LINE, "QTY": Decimal("1.5")}) + + request = route.calls.last.request + assert request.url.params["photon_key"] == KEY + assert "url" not in request.url.params + assert json.loads(request.content) == {**LINE, "QTY": "1.5"} + + +def test_add_line_item_passes_the_documents_url(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.post(ADD_LINE_ITEM_PATH).mock( + return_value=success("Added line item successfully") + ) + client.add_line_item(KEY, LINE, url="https://example.com/invoice.pdf") + + assert route.calls.last.request.url.params["url"] == "https://example.com/invoice.pdf" + + +def test_a_duplicate_line_id_raises_invalid_request(client: PhotonClient) -> None: + message = "Line Item with specified line ID already exists" + with respx.mock(base_url=BASE_URL) as mock: + mock.post(ADD_LINE_ITEM_PATH).mock( + return_value=httpx.Response(403, json={"message": message, "status": "error"}) + ) + with pytest.raises(InvalidRequestError, match=message): + client.add_line_item(KEY, LINE) + + +def test_add_line_item_is_not_retried_after_a_5xx(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.post(ADD_LINE_ITEM_PATH).mock(return_value=httpx.Response(500)) + with pytest.raises(APIError): + client.add_line_item(KEY, LINE) + + assert route.call_count == 1 + + +@pytest.mark.parametrize(("key", "fields"), [("", LINE), (KEY, {}), (KEY, None)]) +def test_add_line_item_validates_before_any_io(client: PhotonClient, key: str, fields: Any) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError): + client.add_line_item(key, fields) + assert not mock.calls + + +# --------------------------------------------------------------------------- # +# delete_line_item +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("line_item_id", [2, "2"]) +def test_delete_line_item_hits_the_items_url(client: PhotonClient, line_item_id: int | str) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.delete(f"{UPDATE_LINE_ITEM_PATH}/2").mock( + return_value=success("Deleted line item successfully") + ) + client.delete_line_item(KEY, line_item_id) + + request = route.calls.last.request + assert request.url.params["photon_key"] == KEY + assert request.content == b"" + + +@pytest.mark.parametrize("line_item_id", ["two", "", "-2", True, None]) +def test_delete_line_item_rejects_a_non_numeric_id(client: PhotonClient, line_item_id: Any) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="line_item_id"): + client.delete_line_item(KEY, line_item_id) + assert not mock.calls + + +def test_delete_line_item_is_not_retried_after_a_5xx(client: PhotonClient) -> None: + # A repeat could delete a different line if the API renumbers after the first. + with respx.mock(base_url=BASE_URL) as mock: + route = mock.delete(f"{UPDATE_LINE_ITEM_PATH}/2").mock(return_value=httpx.Response(503)) + with pytest.raises(APIError): + client.delete_line_item(KEY, 2) + + assert route.call_count == 1 + + +def test_delete_line_item_is_retried_when_the_connection_failed(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.delete(f"{UPDATE_LINE_ITEM_PATH}/2").mock( + side_effect=[httpx.ConnectError("refused"), success("Deleted line item successfully")] + ) + client.delete_line_item(KEY, 2) + + assert route.call_count == 2 + + +def test_delete_line_item_maps_an_invalid_id(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.delete(f"{UPDATE_LINE_ITEM_PATH}/9").mock( + return_value=httpx.Response( + 403, json={"message": "Line Item ID invalid", "status": "error"} + ) + ) + with pytest.raises(InvalidRequestError, match="Line Item ID invalid"): + client.delete_line_item(KEY, 9) diff --git a/tests/test_retries.py b/tests/test_retries.py index ffbb81b..e194149 100644 --- a/tests/test_retries.py +++ b/tests/test_retries.py @@ -227,6 +227,23 @@ def test_a_post_is_retried_when_it_never_left(transport: Transport, error: Excep assert route.calls.last.request.content.count(b"%PDF") == 1 +def test_a_caller_can_mark_a_request_as_unsafe_to_repeat(transport: Transport) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.delete(UPDATE_PATH).mock(return_value=server_error()) + with pytest.raises(APIError): + transport.request_json("DELETE", UPDATE_PATH, idempotent=False) + + assert route.call_count == 1 + + +def test_a_caller_can_mark_a_post_as_safe_to_repeat(transport: Transport) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.post(SUBMIT_PATH).mock(side_effect=[server_error(), ok()]) + transport.request_json("POST", SUBMIT_PATH, idempotent=True) + + assert route.call_count == 2 + + # --------------------------------------------------------------------------- # # How long it waits # --------------------------------------------------------------------------- # From c8eb6a4e0b6914f11cbc50ce16027d3314858377 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 19 Oct 2026 19:11:04 +0530 Subject: [PATCH 3/5] ci: add test matrix and trusted-publishing release workflow --- .github/workflows/ci.yml | 48 ++++++++++++++ .github/workflows/release.yml | 119 ++++++++++++++++++++++++++++++++++ pyproject.toml | 12 ++++ 3 files changed, 179 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d889ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install + run: python -m pip install -e ".[dev]" + - name: Lint + run: | + ruff check . + ruff format --check . + - name: Type-check + run: mypy + - name: Test + run: pytest --cov + + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Build + run: | + python -m pip install build twine + python -m build + python -m twine check --strict dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..48a3ada --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +# Publishes to PyPI when a version tag is pushed: +# +# git tag v0.1.0 && git push origin v0.1.0 +# +# Uses PyPI trusted publishing (OIDC), so no API token is stored in GitHub. One-time +# setup on pypi.org (and test.pypi.org) under the Photon Commerce account: +# Publishing -> Add a pending publisher -> project "photon-sdk", owner +# "Photon-Commerce", repository "photon-sdk-python", workflow "release.yml", +# environment "pypi" (TestPyPI: "testpypi"). +# Then create the "pypi" and "testpypi" environments under the repo's Settings -> +# Environments (adding required reviewers to "pypi" makes each release need approval). +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check the tag matches the package version + run: | + version=$(python -c "import re; print(re.search(r'__version__ = \"(.+?)\"', open('src/photon/_version.py').read()).group(1))") + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match package version ${version}" >&2 + exit 1 + fi + - name: Build + run: | + python -m pip install build twine + python -m build + python -m twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + testpypi: + name: Publish to TestPyPI + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/photon-sdk + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + smoke-test: + name: Install from TestPyPI + needs: testpypi + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install and import + run: | + version="${GITHUB_REF_NAME#v}" + # TestPyPI's index can lag the upload by a minute. + for attempt in 1 2 3 4 5; do + python -m pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ "photon-sdk==${version}" && break + sleep 30 + done + python -c "import photon, sys; assert photon.__version__ == sys.argv[1]; print(photon.__version__)" "${version}" + + pypi: + name: Publish to PyPI + needs: smoke-test + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/photon-sdk + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub release + needs: pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Create release from the changelog + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${GITHUB_REF_NAME#v}" + awk -v v="$version" '$0 ~ "^## \\[" v "\\]" {found=1; next} found && /^## \[/ {exit} found' \ + CHANGELOG.md > notes.md + gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --notes-file notes.md diff --git a/pyproject.toml b/pyproject.toml index 6cabee0..9005f42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,18 @@ path = "src/photon/_version.py" [tool.hatch.build.targets.wheel] packages = ["src/photon"] +# An explicit list: hatch only honours .gitignore, so without it a local build would +# also pick up git-excluded working files. +[tool.hatch.build.targets.sdist] +include = [ + "/src/photon", + "/tests", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/pyproject.toml", +] + [tool.ruff] line-length = 100 src = ["src", "tests"] From 560720e8a86404b0ca1b3e109af2b2884c22bbbc Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 19 Oct 2026 19:11:58 +0530 Subject: [PATCH 4/5] docs: document the SDK and add a changelog and examples --- CHANGELOG.md | 36 ++++++++++ README.md | 138 ++++++++++++++++++++++++++----------- examples/correct_fields.py | 40 +++++++++++ examples/quickstart.py | 38 ++++++++++ pyproject.toml | 2 +- 5 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 examples/correct_fields.py create mode 100644 examples/quickstart.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0b369ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-10-19 + +First public release. + +### Added + +- `PhotonClient`, a synchronous, fully typed client configured from arguments or + `PHOTON_*` environment variables (`PhotonClient.from_env()`), for the sandbox + and production environments. +- `submit()` — upload a document (path, binary file, bytes) or submit a URL, with + webhook, reference ID, subaccount and page-range options. +- `retrieve()` — fetch a result by `photon_key`; raises `NotReadyError` while it + is still processing. +- `extract()` — submit and poll until the result is ready, with a configurable + timeout; `timeout=None` returns the `Submission` for webhook flows. +- `InvoiceDocument` — typed results for invoices and receipt-expenses covering + every documented field, with exact `Decimal` amounts, ISO dates as + `datetime.date`, typed `line_items` and `tax_lines`, dict access by the API's + field names, `.raw`, `to_dict()` and `to_json()`. +- `RawDocument` — dict-style access to results for every other doctype. +- Data correction: `update_fields()`, `update_line_item()`, `add_line_item()` and + `delete_line_item()`. +- A typed exception hierarchy under `PhotonError`, classifying the API's + body-reported failures (quota, invalid key, unsupported file, ...). +- Retries with exponential backoff and jitter for 5xx and network errors. Requests + that could take effect twice are only retried when the connection failed. +- `DEBUG` logging of retries on the `photon` logger, never including credentials + or document keys. + +[0.1.0]: https://github.com/Photon-Commerce/photon-sdk-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 3a6cc7a..d8632fa 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@

+ PyPI SOC 2 GDPR Accuracy Languages - Python 3.9+ + Python 3.10+

@@ -29,7 +30,7 @@ Photon Commerce extracts standardized data from documents (PDFs, images, Word, HTML, and emails) with **99%+ accuracy**, combining AI processing with human verification. Results come back as JSON with 100+ standardized fields across amounts, vendor, bill-to, metadata, line items, and payment details, in **25+ languages**. -This SDK will wrap that API in an ergonomic, fully typed Python client so you can extract a document in a single call. +This SDK wraps that API in a fully typed Python client: extract a document in a single call, read the result as typed attributes, and correct it in place. ## Installation @@ -72,60 +73,102 @@ Get **free sandbox credentials** (20 complimentary extractions, no payment info | **Line Items** | Description, quantity, unit price, amount, SKU, GL code | | **Payment** | Payment terms, bank details, IBAN, routing number | -## How It Works +## Usage -Extraction is a two-step flow. Until the SDK ships, here is the raw API workflow using [`requests`](https://pypi.org/project/requests/) — this works today. +### Credentials + +Pass the five credentials to `PhotonClient`, or set them as environment variables and use `PhotonClient.from_env()`: + +```bash +export PHOTON_CLIENT_ID=... +export PHOTON_USERNAME=... # your account email +export PHOTON_API_KEY=... +export PHOTON_PASSWORD=... +export PHOTON_SECRET_KEY=... +export PHOTON_ENVIRONMENT=sandbox # or "production" +``` ```python -import requests +from photon import PhotonClient -HEADERS = { - "CLIENT-ID": "YOUR_CLIENT_ID", - "AUTHORIZATION": "apikey YOUR_USERNAME:YOUR_API_KEY", - "PASSWORD": "YOUR_PASSWORD", - "SECRET-KEY": "YOUR_SECRET_KEY", -} +with PhotonClient.from_env() as client: + ... ``` -### Submission +### Extract a document -`POST https://sandbox-api.photoncommerce.com/api/pro?doctype=invoice` +`extract()` uploads the document and polls until the result is ready: ```python -response = requests.post( - "https://sandbox-api.photoncommerce.com/api/pro", - headers=HEADERS, - params={"doctype": "invoice"}, - files={"pdf": open("invoice.pdf", "rb")}, -) -photon_key = response.json()["photon_key"] +invoice = client.extract("invoice.pdf", doctype="invoice", timeout=600) + +invoice.vendor_name # "Acme Supplies Ltd" +invoice.total # Decimal("1397.50") +invoice.due_date # datetime.date(2026, 11, 18) +for line in invoice.line_items: + print(line.line, line.description, line.qty, line.amount) + +invoice["Vendor_Name"] # the API's own field names work too +invoice.raw # the untouched payload +invoice.to_dict() # typed fields as a dict; to_json() for JSON +``` + +A document can be a path, an open binary file, raw `bytes`, or a public URL (`client.extract(url="https://...")`). + +Invoices and receipts (`doctype="invoice"` / `"receipt-expense"`) return a typed `InvoiceDocument`: amounts are exact `Decimal`s, ISO dates are `datetime.date`, and anything absent reads as `None`. Every other doctype (`check`, `remittance`, `bol`, `statement`, ...) returns a `RawDocument`, read by the API's field names: `doc["payerName"]`. + +### Submit now, retrieve later + +Processing can take from seconds (AI extraction) to hours (human verification), so you can also split the two steps: + +```python +submission = client.submit("invoice.pdf", doctype="invoice", webhook_url="https://example.com/hook") +# ... later, or once the webhook fires: +invoice = client.retrieve(submission.photon_key, doctype="invoice") ``` -| Parameter | Type | Description | -|-----------|------|-------------| -| `doctype` | string | Document type, e.g. `invoice` | -| `url` | string | URL of a publicly accessible document (alternative to file upload) | -| `webhook_url` | string | Receive a callback when extraction is complete | -| `auth_token` | string | Token to verify the webhook callback | -| `ID` | string | Your own reference ID for this submission | -| `subaccount` | string | Route to a subaccount | -| `page_start` | integer | First page to process (multi-page PDFs) | -| `page_end` | integer | Last page to process (multi-page PDFs) | +`retrieve()` raises `NotReadyError` while the document is still processing. `extract(..., timeout=None)` submits without waiting and returns the `Submission`. -### Retrieval +### Correct extracted data -`GET https://sandbox-api.photoncommerce.com/api/v4/json?photon_key=YOUR_PHOTON_KEY` +Updates use the **API's field names**, exactly as the result spells them: ```python -result = requests.get( - "https://sandbox-api.photoncommerce.com/api/v4/json", - headers=HEADERS, - params={"photon_key": photon_key}, -).json()["data"] +key = invoice.photon_key -print(result["Vendor_Name"], result["Total"], result["Currency_Code"]) +client.update_fields(key, {"Vendor_Name": "Acme Supplies Ltd", "Total": "1397.50"}) +client.update_line_item(key, 2, {"QTY": 3, "Amount": "225.00"}) +client.add_line_item(key, {"Line": 3, "Description": "Shipping", "QTY": 1, "Amount": "25.00"}) +client.delete_line_item(key, 3) ``` +### Errors + +Every error raised by the SDK is a `PhotonError` carrying `status_code`, `message` and the response `body`: + +| Exception | When | +|-----------|------| +| `AuthenticationError` | Credentials rejected (401) | +| `QuotaExceededError` | The account's allowance is used up | +| `InvalidRequestError` | The request or file was rejected (bad file, unknown key, invalid line item, ...) | +| `NotReadyError` | The document is still processing | +| `ExtractionTimeoutError` | `extract()` gave up waiting; retrieve later by `photon_key` | +| `PhotonConnectionError` | The API could not be reached | +| `APIError` | Any other unsuccessful response | +| `ConfigurationError` | Missing credentials or an unknown environment | + +Invalid arguments raise `ValueError` before anything is sent. + +### Retries + +Server errors (5xx) and network failures are retried up to `max_retries` times (default 3) with exponential backoff. Requests that could take effect twice — submitting a document, adding or deleting a line item — are only retried when the connection itself failed. 4xx errors are never retried. + +```python +client = PhotonClient.from_env(max_retries=5, backoff_factor=1.0, timeout=60) +``` + +Retries are logged at `DEBUG` on the `photon` logger; credentials and document keys are never logged. + ## Processing Times | Account Type | Turnaround | @@ -136,7 +179,7 @@ print(result["Vendor_Name"], result["Total"], result["Currency_Code"]) ## Authentication -Every request requires four headers. Free sandbox credentials are available at no cost. +The SDK sends your credentials as four headers on every request. Free sandbox credentials are available at no cost. ``` CLIENT-ID: your-client-id @@ -188,11 +231,22 @@ git clone https://github.com/Photon-Commerce/photon-sdk-python.git cd photon-sdk-python pip install -e ".[dev]" -pytest # run tests -ruff check . # lint -mypy # type-check +pytest --cov # run tests with coverage +ruff check . # lint +ruff format --check . # formatting +mypy # type-check +``` + +## Releasing + +Bump `__version__` in `src/photon/_version.py`, add the version to `CHANGELOG.md`, merge to `main`, then push a matching tag: + +```bash +git tag v0.1.0 && git push origin v0.1.0 ``` +The release workflow builds the package, publishes it to TestPyPI, installs it from there, then publishes to PyPI and creates the GitHub release. + ## Links - **Website:** https://www.photoncommerce.com diff --git a/examples/correct_fields.py b/examples/correct_fields.py new file mode 100644 index 0000000..ff5367d --- /dev/null +++ b/examples/correct_fields.py @@ -0,0 +1,40 @@ +"""Submit an invoice, then correct the extracted data. + +Usage: + export PHOTON_CLIENT_ID=... PHOTON_USERNAME=... PHOTON_API_KEY=... \ + PHOTON_PASSWORD=... PHOTON_SECRET_KEY=... + python examples/correct_fields.py path/to/invoice.pdf +""" + +from __future__ import annotations + +import sys + +from photon import InvoiceDocument, PhotonClient + + +def main(path: str) -> None: + with PhotonClient.from_env() as client: + invoice = client.extract(path, doctype="invoice", timeout=900) + assert isinstance(invoice, InvoiceDocument) + key = invoice.photon_key + assert key is not None + + # Updates use the API's own field names. + client.update_fields(key, {"Payment_Terms": "Net 45"}) + + next_line = max((line.line or 0 for line in invoice.line_items), default=0) + 1 + client.add_line_item( + key, {"Line": next_line, "Description": "Freight", "QTY": 1, "Amount": "25.00"} + ) + client.update_line_item(key, next_line, {"Amount": "30.00"}) + client.delete_line_item(key, next_line) + + corrected = client.retrieve(key, doctype="invoice") + print("Payment terms now:", corrected["Payment_Terms"]) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000..4df67f5 --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,38 @@ +"""Extract an invoice and print its key fields. + +Usage: + export PHOTON_CLIENT_ID=... PHOTON_USERNAME=... PHOTON_API_KEY=... \ + PHOTON_PASSWORD=... PHOTON_SECRET_KEY=... + python examples/quickstart.py path/to/invoice.pdf +""" + +from __future__ import annotations + +import sys + +from photon import ExtractionTimeoutError, InvoiceDocument, PhotonClient + + +def main(path: str) -> None: + with PhotonClient.from_env() as client: + try: + # Trial accounts include human verification, which can take a while. + invoice = client.extract(path, doctype="invoice", timeout=900) + except ExtractionTimeoutError as exc: + print(f"Still processing; retrieve it later. ({exc.message})") + return + + assert isinstance(invoice, InvoiceDocument) + print(f"Vendor: {invoice.vendor_name}") + print(f"Number: {invoice.invoice_number}") + print(f"Date: {invoice.date or invoice.get('Date')}") + print(f"Total: {invoice.total} {invoice.currency_code or ''}") + for line in invoice.line_items: + print(f" {line.line}. {line.description}: {line.qty} x {line.price} = {line.amount}") + print(f"photon_key: {invoice.photon_key}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/pyproject.toml b/pyproject.toml index 9005f42..e82a8ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ src = ["src", "tests"] [tool.mypy] python_version = "3.10" strict = true -files = ["src", "tests"] +files = ["src", "tests", "examples"] [tool.pytest.ini_options] testpaths = ["tests"] From 52663e18a3b5d8c26a66ec4a1ab6ac8d1882ae74 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 19 Oct 2026 19:12:33 +0530 Subject: [PATCH 5/5] chore: release 0.1.0 --- pyproject.toml | 6 +++++- src/photon/_version.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e82a8ea..5c1502a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ license = { text = "MIT" } authors = [{ name = "Photon Commerce" }] keywords = ["photon", "photoncommerce", "documents", "ocr", "sdk"] classifiers = [ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ @@ -39,7 +40,10 @@ dev = [ [project.urls] Homepage = "https://photoncommerce.com" +Documentation = "https://apidocs.photoncommerce.com" Source = "https://github.com/Photon-Commerce/photon-sdk-python" +Issues = "https://github.com/Photon-Commerce/photon-sdk-python/issues" +Changelog = "https://github.com/Photon-Commerce/photon-sdk-python/blob/main/CHANGELOG.md" [tool.hatch.version] path = "src/photon/_version.py" diff --git a/src/photon/_version.py b/src/photon/_version.py index 6807a61..8f1cfab 100644 --- a/src/photon/_version.py +++ b/src/photon/_version.py @@ -8,4 +8,4 @@ __all__ = ["__version__"] -__version__ = "0.0.1" +__version__ = "0.1.0"