Perception
Uploads
Uploads are free and work at any balance. The bytes go from your machine straight to the document service with a one-use token, never through Drex and never with your API key. See Uploads.
Upload a file
client.documents.upload(content, *, path=None, file_name=None, on_conflict=None)
| Parameter | Type | Required | Description |
|---|---|---|---|
content | str | PathLike | bytes | BinaryIO | Yes | A path to the file, its bytes, or an open binary file. |
path | str | No | Path in the workspace, relative (no leading /). Keep the file's extension. Default: file_name. |
file_name | str | For bytes and nameless streams | The name sent with the file, and the default path. Default: the name of the path or stream. Bytes without it raise ValueError. |
on_conflict | "reject" / "new_version" | No | What happens when a different file already has path. reject (the document service's default) fails with path_conflict; new_version stores the upload as that file's next version. |
It returns an UploadedFile. Pass file.as_source() to any tool.
from nace_sdk import NaceClient
with NaceClient() as client:
file = client.documents.upload("invoice.pdf", path="invoices/invoice.pdf", on_conflict="new_version")
job = client.documents.extract(file.as_source(), schema={"type": "object", "properties": {"total": {"type": "number"}}}, wait_seconds=60)- Keep the extension on
path. The document service types an upload from its first bytes, and from the extension when those don't settle it; it refuses a file it can't type withunsupported_file_type. See Format support. - Files of 32 MiB or more (
UPLOAD_SESSION_THRESHOLD_BYTES) go up in parts through an upload session, automatically. - A path conflict raises
NaceConflictErrorwithcodepath_conflictfor a file sent whole. For a file sent in parts it is found when the parts are assembled, and raisesNaceErrornaming the failed upload job andpath_conflict. - Uploading the same bytes to the same path again returns the file already there.
- A file still assembling after about ten minutes raises
NaceError. The document service keeps assembling it; upload the same file to the same path again later to get it.
Upload grants
client.documents.create_upload_grant(**options) mints a one-use token for one upload, so another client can upload without your API key. It returns an UploadGrant.
| Parameter | Type | Required | Description |
|---|---|---|---|
path | str | No | Pin the upload to exactly this path. |
max_bytes | int | No | Refuse a larger file. Default: the document service's limit for the file type. |
total_size_bytes | int | No | The exact size the file must have. |
ttl_seconds | int | No | How long the token works, 1 to 3600. Default: 3600. |
Mint the grant on your server and hand upload_url and token to the browser. The browser POSTs the file there as multipart form data, with a metadata part such as {"path": "...", "on_conflict": "new_version"}, only the X-Upload-Token header, and credentials: "omit". The upload's answer carries the file under result.file; send its workspace_id and file_id back to your server and use them as a WorkspaceFileSource. /v1 routes send no Access-Control-Allow-Origin header, so browser code can't call Drex itself; keep every Drex call on your server.
grant = client.documents.create_upload_grant(path="invoices/invoice.pdf", total_size_bytes=48213, ttl_seconds=600)
print(grant.upload_url, grant.expires_at) # hand grant.upload_url and grant.token to the uploaderUpload sessions
A session uploads a large file in parts, and can resume after a dropped connection. upload runs one for you; drive one yourself to upload parts in parallel or across process restarts.
| Method | Parameters | Returns | Description |
|---|---|---|---|
create_upload_session(**options) | path: str, total_size_bytes: int (required); ttl_seconds: int (1 to 5400, default 5400), on_conflict, idempotency_key | UploadSession | Opens the session. Carries an Idempotency-Key, yours or one minted per call, so a retry never opens a second one. |
upload_part(session, number, data) | session: UploadSession, number: int (from 0), data: bytes | None | Sends one part: session.chunk_size bytes, the last one shorter. Sending a part again replaces it. |
complete_upload_session(session, *, idempotency_key=None) | session, idempotency_key: str | UploadedFile | Assembles the parts into one file and waits for it, for about ten minutes. Calling again with the same key resumes the same completion. |
get_upload_session(session) | session | UploadSessionStatus | The session's state and the parts that landed. |
abort_upload_session(session) | session | None | Abandons the session and discards its parts. Aborting a finished or aborted session does nothing. |
The part, status, complete and abort calls go straight to the session's upload_session_url with its session_token. complete_upload_session raises NaceError when the upload fails, for example with path_conflict, or when the file is still assembling after its wait; that message names the key to call it again with.
A session refuses calls that don't fit its state. None of these is retried:
session_busy(NaceConflictError): sending a part or aborting while the session iscompleting, or completing it with a different key while a completion runs. Callcomplete_upload_sessionagain with the same key to resume that completion.upload_expired(NaceConflictError): sending a part to a session that completed or was aborted, or completing an aborted one. Completing a completed session again returns its file.- After
expires_atthe session token stops working, and every call on the session raisesNaceAuthenticationError(401). Open a new session.
from pathlib import Path
from nace_sdk import NaceClient
source = Path("scan.tiff")
with NaceClient() as client:
session = client.documents.create_upload_session(path="scans/scan.tiff", total_size_bytes=source.stat().st_size)
with source.open("rb") as handle:
# After a drop, send only the parts that haven't landed.
landed = {part["part_number"] for part in client.documents.get_upload_session(session).parts}
for number in range(session.total_parts):
if number not in landed:
handle.seek(number * session.chunk_size)
client.documents.upload_part(session, number, handle.read(session.chunk_size))
file = client.documents.complete_upload_session(session)To resume from another process, save session.model_dump() and rebuild it with UploadSession.model_validate(saved).
Delete a file
client.documents.delete_file(workspace_id, file_id) deletes a file you uploaded: every version of it and everything derived from it. It can't be undone. It's free and works at any balance.
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_id | str | Yes | The file's workspace_id, from UploadedFile. Only your account's workspace. |
file_id | str | Yes | The file's file_id, from UploadedFile. |
It returns a FileDeletion. The call waits while the deletion runs: status is deleted once every trace is confirmed gone, or deleting if it is still running, and calling again then reports on the same deletion. Another account's workspace raises NaceNotFoundError with code workspace_not_found, and a file that isn't there file_not_found.
from nace_sdk import NaceClient
with NaceClient() as client:
file = client.documents.upload("invoice.pdf")
deleted = client.documents.delete_file(file.workspace_id, file.file_id)
print(deleted.status) # deleted