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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

All notable changes to `fopost` are documented here.

## Unreleased

### Added

- `media.limits(workspace_id=None)` returns an `UploadLimits` model: `max_file_bytes`,
`multipart_max_file_bytes`, `max_files_per_upload`, `next_tier`, `allowed_types` and the
source of the ceiling.
- `posts.create` and `posts.update` take `files` (paths, open binary files, or
`(filename, bytes, mime_type)` tuples). Files that fit a multipart request go in one
request to the post endpoint; larger files are uploaded directly and attached to the
first content block.
- `media.presign` and `media.upload_direct` accept `folder_id`, `origin`, `brand_role`,
`brand_voice_id` and `person_consent`. `PresignedUpload.max_file_bytes` reports the
ceiling the slot was issued under.
- `FileTooLargeError` for HTTP 413 `file_too_large`, with `max_file_bytes`, `file_bytes`
and `upgrade_url`.

### Changed

- Presign accepts files up to 5 GB; the limit now depends on the account rather than a
fixed size.
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,42 @@ print(item.id, item.preview_url)
```

`presign` and `complete` are the two halves, for when you want to send the bytes
yourself.
yourself. Both take the optional `folder_id`, `origin`, `brand_role`, `brand_voice_id`
and `person_consent` fields the library accepts.

### Upload limits

`media.limits` reports what the account may upload. `max_file_bytes` is the ceiling for
any route (it grows with the number of connected accounts, or with the Large Uploads
add-on); `multipart_max_file_bytes` is the most one multipart request may carry per
file, above which a file needs `upload_direct`:

```python
limits = client.media.limits(workspace_id=workspace.id)
print(limits.max_file_bytes, limits.multipart_max_file_bytes, limits.next_tier)
```

A file over the ceiling raises `FileTooLargeError` (HTTP 413, `error == "file_too_large"`)
with `max_file_bytes`, `file_bytes` and `upgrade_url` readable on the error.

## Files on posts

`posts.create` and `posts.update` take `files`: paths, open binary files, or
`(filename, bytes, mime_type)` tuples. They are stored in the workspace library and
attached to the first content block:

```python
post = client.posts.create(
workspace_id=workspace.id,
content="Launch day",
accounts=[a.id for a in accounts],
files=["launch.png", ("clip.mp4", clip_bytes, "video/mp4")],
)
```

The SDK picks the route: when every file fits in a multipart request it sends one, and
otherwise it uploads each file directly and attaches the results before sending the
post. On `update` with no `content`, the files are appended to the post's first block.

## Configuration

Expand Down Expand Up @@ -240,7 +275,7 @@ except FopostError as err:
| `account_groups` | `list`, `create`, `get`, `update`, `delete`, `set_members` |
| `workspaces` | `list`, `get` |
| `labels` | `list` |
| `media` | `presign`, `complete`, `upload_direct` |
| `media` | `limits`, `presign`, `complete`, `upload_direct` |
| `ai` | `credits`, `generate_caption`, `rewrite`, `repurpose_url` |
| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `handover`, `list_approvals`, `approve_reply`, `reject_reply` |
| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` |
Expand Down
6 changes: 6 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .client import Fopost
from .errors import (
AuthenticationError,
FileTooLargeError,
FopostError,
NotFoundError,
PaymentRequiredError,
Expand Down Expand Up @@ -197,6 +198,8 @@
TimelineDelivery,
TimelineDelta,
TimelinePoint,
UploadLimits,
UploadLimitTier,
ValidateLengthCheck,
ValidateLengthResult,
ValidateMediaResult,
Expand Down Expand Up @@ -252,6 +255,7 @@
"Delivery",
"ExternalAd",
"FeedLead",
"FileTooLargeError",
"FoPost",
"Fopost",
"FopostError",
Expand Down Expand Up @@ -315,6 +319,8 @@
"PostStatus",
"PresignedUpload",
"RateLimitError",
"UploadLimitTier",
"UploadLimits",
"ReachEstimate",
"RepurposeResult",
"RewriteResult",
Expand Down
26 changes: 24 additions & 2 deletions src/fopost/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import time
from collections.abc import Sequence
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import urlsplit
Expand Down Expand Up @@ -86,9 +87,18 @@ def request(
*,
json: Any | None = None,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
files: Sequence[tuple[str, tuple[str, bytes, str]]] | None = None,
) -> Any:
"""Send a request, retrying on 429, and return the decoded body."""
"""Send a request, retrying on 429, and return the decoded body.

``data`` and ``files`` switch the body to ``multipart/form-data``.
"""
clean_params = {k: v for k, v in params.items() if v is not None} if params else None
headers = self.headers
if files is not None:
# httpx sets the multipart boundary itself.
headers = {k: v for k, v in headers.items() if k != "Content-Type"}

attempt = 0
while True:
Expand All @@ -97,8 +107,10 @@ def request(
method,
self._url(path),
json=json,
data=data,
files=files,
params=clean_params,
headers=self.headers,
headers=headers,
)

if response.status_code == 429 and attempt < self.max_retries:
Expand Down Expand Up @@ -147,6 +159,16 @@ def post(self, path: str, json: Any | None = None) -> Any:
def put(self, path: str, json: Any | None = None) -> Any:
return self.request("PUT", path, json=json)

def send_multipart(
self,
method: str,
path: str,
*,
fields: dict[str, Any],
files: Sequence[tuple[str, tuple[str, bytes, str]]],
) -> Any:
return self.request(method, path, data=fields, files=files)

def patch(self, path: str, json: Any | None = None) -> Any:
return self.request("PATCH", path, json=json)

Expand Down
33 changes: 33 additions & 0 deletions src/fopost/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"PaymentRequiredError",
"PermissionDeniedError",
"NotFoundError",
"FileTooLargeError",
"RateLimitError",
"error_from_response",
]
Expand Down Expand Up @@ -69,6 +70,37 @@ class NotFoundError(FopostError):
"""404 — no such resource, or it is outside the key's reach."""


class FileTooLargeError(FopostError):
"""413 ``file_too_large`` — a file is over the account's upload ceiling.

``max_file_bytes`` is the largest file the account may upload, ``file_bytes``
the size that was refused, and ``upgrade_url`` where to raise the limit.
"""

def _int(self, key: str) -> int | None:
if isinstance(self.body, dict):
value = self.body.get(key)
if isinstance(value, int) and not isinstance(value, bool):
return value
return None

@property
def max_file_bytes(self) -> int | None:
return self._int("max_file_bytes")

@property
def file_bytes(self) -> int | None:
return self._int("file_bytes")

@property
def upgrade_url(self) -> str | None:
if isinstance(self.body, dict):
value = self.body.get("upgrade_url")
if isinstance(value, str):
return value
return None


class RateLimitError(FopostError):
"""429 — rate limit exceeded. ``retry_after`` is in seconds when sent."""

Expand All @@ -90,6 +122,7 @@ def __init__(
402: PaymentRequiredError,
403: PermissionDeniedError,
404: NotFoundError,
413: FileTooLargeError,
429: RateLimitError,
}

Expand Down
28 changes: 28 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@
"PostAccount",
"PostStatus",
"PresignedUpload",
"UploadLimitTier",
"UploadLimits",
"ReachEstimate",
"RepurposeResult",
"RewriteResult",
Expand Down Expand Up @@ -296,6 +298,32 @@ class PresignedUpload(FopostModel):
method: str = "PUT"
headers: dict[str, str] = {}
expires_at: datetime | None = None
max_file_bytes: int | None = None


class UploadLimitTier(FopostModel):
"""The next account-count band and the ceiling it unlocks."""

up_to_accounts: int | None = None
max_file_bytes: int


class UploadLimits(FopostModel):
"""What the account may upload, from ``GET /media/limits``.

``max_file_bytes`` is the ceiling for any route; ``multipart_max_file_bytes`` is the
most one multipart part may carry, above which a file needs a direct upload.
"""

max_file_bytes: int
source: Literal["accounts", "add_on", "bypass"] | str
connected_accounts: int
large_uploads_enabled: bool
next_tier: UploadLimitTier | None = None
multipart_max_file_bytes: int
max_files_per_upload: int
direct_upload_available: bool
allowed_types: list[str] = []


class ContentBlock(FopostModel):
Expand Down
60 changes: 54 additions & 6 deletions src/fopost/resources/media.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
"""``client.media`` — direct uploads through a presigned URL."""
"""``client.media`` — upload limits and direct uploads through a presigned URL."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from .._http import unwrap
from ..errors import error_from_response
from ..models import MediaItem, PresignedUpload
from ..models import MediaItem, PresignedUpload, UploadLimits
from ._base import Resource

__all__ = ["MediaResource"]

#: What a multipart request may carry per file when ``GET /media/limits`` is unreachable.
DEFAULT_MULTIPART_MAX_FILE_BYTES = 100 * 1024 * 1024


class MediaResource(Resource):
def limits(self, *, workspace_id: str | None = None) -> UploadLimits:
"""The upload ceilings for the caller's account."""
return UploadLimits.model_validate(
unwrap(self._http.get("/media/limits", {"workspaceId": workspace_id}))
)

def presign(
self, *, workspace_id: str, filename: str, mime_type: str, size: int
self,
*,
workspace_id: str,
filename: str,
mime_type: str,
size: int,
folder_id: str | None = None,
origin: str | None = None,
brand_role: str | None = None,
brand_voice_id: str | None = None,
person_consent: Mapping[str, str] | None = None,
) -> PresignedUpload:
"""Reserve an upload slot for a file of ``size`` bytes."""
body = {
body: dict[str, Any] = {
"workspaceId": workspace_id,
"filename": filename,
"mimeType": mime_type,
"size": size,
}
optional = {
"folderId": folder_id,
"origin": origin,
"brandRole": brand_role,
"brandVoiceId": brand_voice_id,
"personConsent": dict(person_consent) if person_consent is not None else None,
}
body.update({k: v for k, v in optional.items() if v is not None})
return PresignedUpload.model_validate(unwrap(self._http.post("/media/presign", body)))

def complete(self, upload_id: str) -> MediaItem:
Expand All @@ -30,11 +60,29 @@ def complete(self, upload_id: str) -> MediaItem:
)

def upload_direct(
self, *, workspace_id: str, filename: str, mime_type: str, data: bytes
self,
*,
workspace_id: str,
filename: str,
mime_type: str,
data: bytes,
folder_id: str | None = None,
origin: str | None = None,
brand_role: str | None = None,
brand_voice_id: str | None = None,
person_consent: Mapping[str, str] | None = None,
) -> MediaItem:
"""Presign, PUT the bytes to storage, then complete. Returns the library item."""
slot = self.presign(
workspace_id=workspace_id, filename=filename, mime_type=mime_type, size=len(data)
workspace_id=workspace_id,
filename=filename,
mime_type=mime_type,
size=len(data),
folder_id=folder_id,
origin=origin,
brand_role=brand_role,
brand_voice_id=brand_voice_id,
person_consent=person_consent,
)
# Storage takes the raw bytes with the presigned headers only, never the API key.
headers = {**slot.headers, "Content-Length": str(len(data))}
Expand Down
Loading
Loading