Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ classifiers = [
]
dependencies = [
"httpx>=0.27",
"pydantic>=2",
]

[project.optional-dependencies]
Expand Down
4 changes: 4 additions & 0 deletions src/photon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from ._version import __version__
from .client import PhotonClient
from .config import Config
from .constants import DocType, Environment
from .exceptions import (
Expand All @@ -16,6 +17,7 @@
PhotonError,
QuotaExceededError,
)
from .models import Submission

__all__ = [
"APIError",
Expand All @@ -27,8 +29,10 @@
"ExtractionTimeoutError",
"InvalidRequestError",
"NotReadyError",
"PhotonClient",
"PhotonConnectionError",
"PhotonError",
"QuotaExceededError",
"Submission",
"__version__",
]
30 changes: 25 additions & 5 deletions src/photon/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Classification reads the response **body** before the status code, because the API
puts the real outcome in the body's ``message``: a document that is still
processing comes back as an HTTP 200, and several genuine failures come back as
403. See ``plans/API-REFERENCE.md`` for the observed responses.
403. See the official API docs (apidocs.photoncommerce.com) for the responses.
"""

from __future__ import annotations
Expand Down Expand Up @@ -66,7 +66,13 @@ def __init__(self, config: Config) -> None:
base_url=config.base_url,
headers={**build_auth_headers(config), "User-Agent": USER_AGENT},
timeout=config.timeout,
follow_redirects=True,
# Never follow redirects: httpx strips only the Authorization header
# on a cross-origin redirect, so following one would re-send the
# CLIENT-ID, PASSWORD, and SECRET-KEY credentials to the redirect
# target (and a redirected POST is re-issued as a bodyless GET). The
# documented API never redirects; if an endpoint ever does, handle
# it explicitly without credentials rather than re-enabling this.
follow_redirects=False,
)

def request(
Expand Down Expand Up @@ -221,11 +227,23 @@ def _raise_for_response(response: httpx.Response, body: Any) -> None:
message = _extract_message(response, body)

if response.is_success:
# A still-processing document is reported as a successful response.
if PROCESSING_MARKER in message.lower():
# A still-processing document is reported as a successful JSON response.
# Only a JSON body can carry that signal — matching it against other
# content would misread a downloaded document that happens to contain
# the words.
if isinstance(body, dict) and PROCESSING_MARKER in message.lower():
raise NotReadyError(message, status_code=status, body=body)
return

if response.is_redirect:
location = response.headers.get("location", "unknown")
raise APIError(
f"Unexpected redirect to {location!r}. The SDK does not follow "
"redirects, to avoid re-sending credentials to another host.",
status_code=status,
body=body,
)

if status == httpx.codes.UNAUTHORIZED:
raise AuthenticationError(message, status_code=status, body=body)

Expand Down Expand Up @@ -281,7 +299,9 @@ def _text_snippet(response: httpx.Response) -> str:
def _describe_content(response: httpx.Response) -> str:
"""Describe a response body that could not be used as JSON."""
content_type = response.headers.get("content-type", "")
return f"content-type {content_type!r}" if content_type else "an empty body"
if content_type:
return f"content-type {content_type!r}"
return "a body with no content-type" if response.content else "an empty body"


def _drop_none(params: dict[str, Any] | None) -> dict[str, Any] | None:
Expand Down
292 changes: 292 additions & 0 deletions src/photon/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
"""The public synchronous client.

:class:`PhotonClient` is the SDK's front door: configure it once with the five
Photon credentials, then call its methods. HTTP mechanics and error
classification live in :mod:`photon._transport`; this module owns argument
validation, request shaping, and response models.
"""

from __future__ import annotations

import contextlib
import os
import re
from types import TracebackType
from typing import IO, TYPE_CHECKING, Any, cast

from ._transport import Transport
from .config import Config
from .constants import (
DEFAULT_BACKOFF_FACTOR,
DEFAULT_MAX_RETRIES,
DEFAULT_TIMEOUT,
RETRIEVE_PATH,
SUBMIT_PATH,
DocType,
Environment,
)
from .exceptions import APIError
from .models import Submission

if TYPE_CHECKING:
# typing.Self only exists from 3.11; type checkers bundle typing_extensions,
# so this needs no runtime dependency.
from typing_extensions import Self

__all__ = ["PhotonClient"]

# What ``submit`` accepts as a document: a filesystem path, an open binary file
# object, or the file's bytes.
DocumentInput = str | os.PathLike[str] | IO[bytes] | bytes

# The multipart form field the API expects the uploaded file in, whatever the
# actual file type.
_FILE_FIELD = "pdf"

# Filename sent for raw bytes and unnamed file objects. The API recognises file
# types by extension, so the upload must carry one; callers whose content is not
# a PDF should pass a path or a named file object instead.
_DEFAULT_FILENAME = "upload.pdf"

_SUBACCOUNT_MAX_LEN = 50
_SUBACCOUNT_RE = re.compile(r"[A-Za-z0-9-]+")


class PhotonClient:
"""Synchronous client for the Photon Commerce API.

Use it as a context manager, or call :meth:`close` when finished, so the
connection pool is released.

Args:
client_id: The ``CLIENT-ID`` credential.
username: The account username (email).
api_key: The API key.
password: The ``PASSWORD`` credential.
secret_key: The ``SECRET-KEY`` credential.
environment: Which API environment to target, ``"sandbox"`` (default)
or ``"production"``.
base_url: Overrides the environment's base URL when set.
timeout: Per-request timeout, in seconds.
max_retries: How many times to retry transient failures.
backoff_factor: Base delay (seconds) for exponential retry backoff.

Raises:
ConfigurationError: If a credential is missing or the environment is
unknown.
"""

def __init__(
self,
client_id: str = "",
username: str = "",
api_key: str = "",
password: str = "",
secret_key: str = "",
*,
environment: Environment | str = Environment.SANDBOX,
base_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
max_retries: int = DEFAULT_MAX_RETRIES,
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
) -> None:
self.config = Config(
client_id=client_id,
username=username,
api_key=api_key,
password=password,
secret_key=secret_key,
# Config coerces a string environment itself, with a better error.
environment=cast(Environment, environment),
base_url=base_url or "",
timeout=timeout,
max_retries=max_retries,
backoff_factor=backoff_factor,
)
self._transport = Transport(self.config)

@classmethod
def from_env(cls, **overrides: Any) -> PhotonClient:
"""Build a client from ``PHOTON_*`` environment variables.

See :meth:`Config.from_env` for the variables read. Explicit keyword
``overrides`` take precedence over the environment, e.g.
``PhotonClient.from_env(environment="production")``.
"""
config = Config.from_env(**overrides)
return cls(
client_id=config.client_id,
username=config.username,
api_key=config.api_key,
password=config.password,
secret_key=config.secret_key,
environment=config.environment,
base_url=config.base_url,
timeout=config.timeout,
max_retries=config.max_retries,
backoff_factor=config.backoff_factor,
)

def submit(
self,
document: DocumentInput | None = None,
*,
doctype: DocType | str = DocType.INVOICE,
url: str | None = None,
webhook_url: str | None = None,
auth_token: str | None = None,
reference_id: str | None = None,
subaccount: str | None = None,
page_start: int | None = None,
page_end: int | None = None,
) -> Submission:
"""Submit a document for extraction.

Exactly one of ``document`` and ``url`` must be given.

Args:
document: The document to upload — a path, an open binary file
object, or raw bytes. A path is opened and closed by the
client; a file object is read as-is and left open. The API
recognises file types by filename extension, so raw bytes and
unnamed file objects are uploaded as ``upload.pdf``; for other
file types, pass a path or a file object with a ``.name``.
doctype: What kind of document this is; the API defaults to
invoice. Any string is passed through, so doctypes newer than
this SDK still work.
url: Publicly fetchable URL of the document, as an alternative to
uploading it.
webhook_url: Endpoint Photon calls (POST) when processing finishes.
auth_token: Value Photon echoes back in the webhook's
``Authorization`` header, so the receiver can verify the sender.
reference_id: Your own correlation ID, echoed in the webhook body.
Sent to the API as ``ID``.
subaccount: Sub-account to attribute this call to. Letters, digits,
and hyphens; at most 50 characters.
page_start: First page to process (1-based, inclusive).
page_end: Last page to process (1-based, inclusive).

Returns:
A :class:`Submission` carrying the ``photon_key`` (to retrieve the
result) and ``doc_path`` (to fetch or delete the original later).

Raises:
ValueError: Neither or both of ``document``/``url`` given, or an
invalid ``subaccount`` — checked before any I/O.
PhotonError: See :meth:`Transport.request_json` for the mapping.
"""
if (document is None) == (url is None):
raise ValueError("Pass exactly one of 'document' or 'url'.")
if subaccount is not None and not _is_valid_subaccount(subaccount):
raise ValueError(
"subaccount must be 1-50 characters of letters, digits, or hyphens."
)

params: dict[str, Any] = {
"doctype": doctype.value if isinstance(doctype, DocType) else str(doctype),
"url": url,
"webhook_url": webhook_url,
"auth_token": auth_token,
"ID": reference_id,
"subaccount": subaccount,
"page_start": page_start,
"page_end": page_end,
}

# A path is opened (and closed) here; a caller's file object is not ours
# to close, so it never enters the stack.
with contextlib.ExitStack() as cleanup:
files: Any = None
if isinstance(document, bytes):
files = {_FILE_FIELD: (_DEFAULT_FILENAME, document)}
elif isinstance(document, (str, os.PathLike)):
handle = cleanup.enter_context(open(document, "rb"))
files = {_FILE_FIELD: (os.path.basename(os.fspath(document)), handle)}
elif document is not None:
files = {_FILE_FIELD: (_filename_for(document), document)}

body = self._transport.request_json(
"POST", SUBMIT_PATH, params=params, files=files
)

return Submission.from_response(body)

def retrieve(self, photon_key: str) -> dict[str, Any]:
"""Fetch the extraction result for a submitted document.

Args:
photon_key: The key from :attr:`Submission.photon_key`.

Returns:
The extracted fields, exactly as the API returned them (the body's
``data`` object). Field names vary by doctype — see the doctype
family schemas in the API reference.

Raises:
ValueError: ``photon_key`` is empty — checked before any I/O.
NotReadyError: The document is still being processed; retry later.
APIError: The response reported success but carried no ``data``
object.
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.")

body = self._transport.request_json(
"GET", RETRIEVE_PATH, params={"photon_key": photon_key}
)

data = body.get("data")
if not isinstance(data, dict):
raise APIError(
"The response reported success but did not include a 'data' object.",
body=body,
)
return data

def close(self) -> None:
"""Close the underlying connection pool. Safe to call more than once."""
self._transport.close()

@property
def is_closed(self) -> bool:
"""Whether :meth:`close` has been called."""
return self._transport.is_closed

def __enter__(self) -> Self:
return self

def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.close()

def __repr__(self) -> str:
return (
f"PhotonClient(environment={self.config.environment.value!r}, "
f"base_url={self.config.base_url!r})"
)


def _filename_for(document: IO[bytes]) -> str:
"""The filename to upload a file object under.

The API recognises file types by the uploaded filename's extension, so an
unnamed stream (``BytesIO``, a pipe, a fd-opened file) gets the default
rather than httpx's extensionless fallback.
"""
name = getattr(document, "name", None)
if isinstance(name, str) and os.path.basename(name):
return os.path.basename(name)
return _DEFAULT_FILENAME


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)
)
Loading