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 @@
+
-
+
@@ -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"