From b6738ef173056645a54bde662c5020b340112a5b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Tue, 13 Oct 2026 23:33:54 +0200 Subject: [PATCH 1/5] feat(errors): map 413 file_too_large to FileTooLargeError --- src/fopost/__init__.py | 6 ++++++ src/fopost/errors.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index f8b0f81..82bf2f0 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -22,6 +22,7 @@ from .client import Fopost from .errors import ( AuthenticationError, + FileTooLargeError, FopostError, NotFoundError, PaymentRequiredError, @@ -197,6 +198,8 @@ TimelineDelivery, TimelineDelta, TimelinePoint, + UploadLimits, + UploadLimitTier, ValidateLengthCheck, ValidateLengthResult, ValidateMediaResult, @@ -252,6 +255,7 @@ "Delivery", "ExternalAd", "FeedLead", + "FileTooLargeError", "FoPost", "Fopost", "FopostError", @@ -315,6 +319,8 @@ "PostStatus", "PresignedUpload", "RateLimitError", + "UploadLimitTier", + "UploadLimits", "ReachEstimate", "RepurposeResult", "RewriteResult", diff --git a/src/fopost/errors.py b/src/fopost/errors.py index 8c6e38e..cb2ebf6 100644 --- a/src/fopost/errors.py +++ b/src/fopost/errors.py @@ -15,6 +15,7 @@ "PaymentRequiredError", "PermissionDeniedError", "NotFoundError", + "FileTooLargeError", "RateLimitError", "error_from_response", ] @@ -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.""" @@ -90,6 +122,7 @@ def __init__( 402: PaymentRequiredError, 403: PermissionDeniedError, 404: NotFoundError, + 413: FileTooLargeError, 429: RateLimitError, } From d3996654b78b8ea616b27df1e68e6b8d4f1cd962 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Tue, 13 Oct 2026 23:33:54 +0200 Subject: [PATCH 2/5] feat(http): send multipart/form-data bodies --- src/fopost/_http.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/fopost/_http.py b/src/fopost/_http.py index 1a0ab0e..0ef1176 100644 --- a/src/fopost/_http.py +++ b/src/fopost/_http.py @@ -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 @@ -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: @@ -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: @@ -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) From b861a8e7a8358dfae32bca1624926f5647a8371e Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Tue, 13 Oct 2026 23:33:54 +0200 Subject: [PATCH 3/5] feat(media): add limits() and the presign library fields --- src/fopost/models.py | 28 +++++++++ src/fopost/resources/media.py | 60 +++++++++++++++++-- tests/test_media.py | 110 +++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 7 deletions(-) diff --git a/src/fopost/models.py b/src/fopost/models.py index 4f09263..cd78d73 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -99,6 +99,8 @@ "PostAccount", "PostStatus", "PresignedUpload", + "UploadLimitTier", + "UploadLimits", "ReachEstimate", "RepurposeResult", "RewriteResult", @@ -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): diff --git a/src/fopost/resources/media.py b/src/fopost/resources/media.py index 1b061ef..0feecf8 100644 --- a/src/fopost/resources/media.py +++ b/src/fopost/resources/media.py @@ -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: @@ -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))} diff --git a/tests/test_media.py b/tests/test_media.py index 2016d0b..825c7c4 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -6,7 +6,7 @@ import pytest import respx -from fopost import Fopost, FopostError, NotFoundError +from fopost import FileTooLargeError, Fopost, FopostError, NotFoundError from tests.conftest import API_KEY, BASE_URL UPLOAD_URL = "https://storage.test.fopost.com/uploads/up_1?sig=abc" @@ -116,3 +116,111 @@ def test_complete_maps_an_unknown_upload_to_not_found(client: Fopost) -> None: with pytest.raises(NotFoundError): client.media.complete("nope") + + +LIMITS_FIXTURE = { + "maxFileBytes": 104857600, + "source": "accounts", + "connectedAccounts": 2, + "largeUploadsEnabled": False, + "nextTier": {"upToAccounts": 7, "maxFileBytes": 419430400}, + "multipartMaxFileBytes": 104857600, + "maxFilesPerUpload": 5, + "directUploadAvailable": True, + "allowedTypes": ["image/png", "video/mp4"], +} + + +@respx.mock +def test_limits_reads_the_ceilings_for_a_workspace(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS_FIXTURE}) + ) + + limits = client.media.limits(workspace_id="ws_1") + + assert dict(route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert route.calls.last.request.headers["x-api-key"] == API_KEY + assert limits.max_file_bytes == 104857600 + assert limits.source == "accounts" + assert limits.multipart_max_file_bytes == 104857600 + assert limits.max_files_per_upload == 5 + assert limits.next_tier is not None + assert limits.next_tier.up_to_accounts == 7 + assert limits.next_tier.max_file_bytes == 419430400 + assert limits.allowed_types == ["image/png", "video/mp4"] + + +@respx.mock +def test_limits_without_a_workspace_sends_no_query(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS_FIXTURE}) + ) + + client.media.limits() + + assert dict(route.calls.last.request.url.params) == {} + + +@respx.mock +def test_presign_sends_the_optional_fields_and_reads_max_file_bytes(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/media/presign").mock( + return_value=httpx.Response( + 201, json={"data": {**PRESIGN_FIXTURE, "maxFileBytes": 5368709120}} + ) + ) + + slot = client.media.presign( + workspace_id="ws_1", + filename="clip.mp4", + mime_type="video/mp4", + size=5368709120, + folder_id="fld_1", + origin="chat", + brand_role="person", + brand_voice_id="bv_1", + person_consent={"subject": "Jane", "statement": "I agree"}, + ) + + assert slot.max_file_bytes == 5368709120 + assert json.loads(route.calls.last.request.content) == { + "workspaceId": "ws_1", + "filename": "clip.mp4", + "mimeType": "video/mp4", + "size": 5368709120, + "folderId": "fld_1", + "origin": "chat", + "brandRole": "person", + "brandVoiceId": "bv_1", + "personConsent": {"subject": "Jane", "statement": "I agree"}, + } + + +@respx.mock +def test_a_413_surfaces_as_file_too_large_with_the_ceiling(client: Fopost) -> None: + respx.post(f"{BASE_URL}/media/presign").mock( + return_value=httpx.Response( + 413, + json={ + "error": "file_too_large", + "message": "This file is over your upload limit.", + "max_file_bytes": 104857600, + "file_bytes": 209715200, + "upgrade_url": "/settings/billing", + }, + ) + ) + + with pytest.raises(FileTooLargeError) as excinfo: + client.media.presign( + workspace_id="ws_1", filename="big.mp4", mime_type="video/mp4", size=209715200 + ) + + error = excinfo.value + assert isinstance(error, FopostError) + assert error.status == 413 + assert error.code == "file_too_large" + assert error.max_file_bytes == 104857600 + assert error.file_bytes == 209715200 + assert error.upgrade_url == "/settings/billing" + assert error.body["max_file_bytes"] == 104857600 From 809f05e6eb29c7a37cd53954ff06cd884bac0d7e Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Tue, 13 Oct 2026 23:33:54 +0200 Subject: [PATCH 4/5] feat(posts): attach files on create and update --- src/fopost/resources/posts.py | 115 +++++++++++++++++- tests/test_posts_files.py | 218 ++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 tests/test_posts_files.py diff --git a/src/fopost/resources/posts.py b/src/fopost/resources/posts.py index d519ba3..09300cf 100644 --- a/src/fopost/resources/posts.py +++ b/src/fopost/resources/posts.py @@ -3,20 +3,72 @@ from __future__ import annotations import builtins +import json +import mimetypes +import os from collections.abc import Iterator, Mapping, Sequence from datetime import datetime -from typing import Any +from pathlib import Path +from typing import IO, Any + +import httpx from .._http import unwrap -from ..models import ContentBlock, Delivery, Page, PageMeta, Post, SocialAccount +from ..errors import FopostError +from ..models import ContentBlock, Delivery, MediaItem, Page, PageMeta, Post, SocialAccount from ._base import UNSET, Resource, parse_list +from .media import DEFAULT_MULTIPART_MAX_FILE_BYTES, MediaResource ContentInput = str | Mapping[str, Any] | ContentBlock AccountInput = str | Mapping[str, Any] | SocialAccount +FileInput = str | os.PathLike[str] | IO[bytes] | tuple[str, bytes, str] +FilePart = tuple[str, bytes, str] __all__ = ["PostsResource"] +def _read_file(file: FileInput) -> FilePart: + """Turn a path, an open binary file, or ``(filename, bytes, mime_type)`` into a part.""" + if isinstance(file, tuple): + filename, data, mime_type = file + return filename, bytes(data), mime_type + if isinstance(file, (str, os.PathLike)): + path = Path(os.fspath(file)) + filename, data = path.name, path.read_bytes() + else: + data = file.read() + raw_name = getattr(file, "name", None) + filename = Path(raw_name).name if isinstance(raw_name, str) else "upload" + mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + return filename, data, mime_type + + +def _media_type(mime_type: str) -> str: + if mime_type == "image/gif": + return "gif" + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("video/"): + return "video" + return "document" + + +def _attach_media(blocks: list[dict[str, Any]], items: list[tuple[MediaItem, FilePart]]) -> None: + """Append uploaded items to the first block's media.""" + if not blocks: + blocks.append({"text": ""}) + media = blocks[0].setdefault("media", []) + for item, (filename, data, mime_type) in items: + media.append( + { + "type": _media_type(mime_type), + "name": item.name or filename, + "url": item.url, + "size": item.size if item.size is not None else len(data), + } + ) + + def _normalize_content(content: str | Sequence[ContentInput]) -> list[dict[str, Any]]: """Accept a bare string, one block, or a sequence of blocks.""" blocks: Sequence[ContentInput] @@ -165,6 +217,7 @@ def create( summary: str | None = None, content_type: str | None = None, settings: Mapping[str, Mapping[str, Any]] | None = None, + files: Sequence[FileInput] | None = None, **extra: Any, ) -> Post: """Create a draft or a scheduled post. @@ -173,6 +226,10 @@ def create( ``schedule_at``. To send a post out now, create it and call :meth:`publish`. ``account_group_id`` adds that group's accounts to ``accounts``, so ``accounts`` may be left empty. + + ``files`` are stored in the workspace library and attached to the first + content block. Each is a path, an open binary file, or a + ``(filename, bytes, mime_type)`` tuple. """ body: dict[str, Any] = { "workspace_id": workspace_id, @@ -191,6 +248,13 @@ def create( } body.update({k: v for k, v in optional.items() if v is not None}) body.update(extra) + if files: + parts = [_read_file(f) for f in files] + if self._fits_multipart(parts, workspace_id): + return Post.model_validate( + unwrap(self._send_with_files("POST", "/posts", body, parts)) + ) + _attach_media(body["content"], self._upload_all(workspace_id, parts)) return Post.model_validate(unwrap(self._http.post("/posts", body))) def update( @@ -206,9 +270,13 @@ def update( summary: str | None | Any = UNSET, content_type: str | Any = UNSET, settings: Mapping[str, Mapping[str, Any]] | Any = UNSET, + files: Sequence[FileInput] | None = None, **extra: Any, ) -> Post: - """Partial update — only the fields you pass are sent.""" + """Partial update — only the fields you pass are sent. + + ``files`` are attached to the first content block, as in :meth:`create`. + """ body: dict[str, Any] = {} if content is not UNSET: body["content"] = _normalize_content(content) @@ -229,8 +297,49 @@ def update( if settings is not UNSET: body["settings"] = dict(settings) body.update(extra) + if files: + parts = [_read_file(f) for f in files] + if self._fits_multipart(parts, None): + return Post.model_validate( + unwrap(self._send_with_files("PUT", f"/posts/{post_id}", body, parts)) + ) + current = self.get(post_id) + if current.workspace_id is None: + raise FopostError("Post has no workspace to upload into", status=0) + if "content" not in body: + body["content"] = _normalize_content(current.content) + _attach_media(body["content"], self._upload_all(current.workspace_id, parts)) return Post.model_validate(unwrap(self._http.put(f"/posts/{post_id}", body))) + def _fits_multipart(self, parts: Sequence[FilePart], workspace_id: str | None) -> bool: + try: + ceiling = MediaResource(self._http).limits(workspace_id=workspace_id) + limit = ceiling.multipart_max_file_bytes + except (FopostError, httpx.HTTPError): + limit = DEFAULT_MULTIPART_MAX_FILE_BYTES + return all(len(data) <= limit for _, data, _ in parts) + + def _send_with_files( + self, method: str, path: str, body: dict[str, Any], parts: Sequence[FilePart] + ) -> Any: + return self._http.send_multipart( + method, path, fields={"post": json.dumps(body)}, files=[("files", p) for p in parts] + ) + + def _upload_all( + self, workspace_id: str, parts: Sequence[FilePart] + ) -> builtins.list[tuple[MediaItem, FilePart]]: + media = MediaResource(self._http) + return [ + ( + media.upload_direct( + workspace_id=workspace_id, filename=name, mime_type=mime, data=data + ), + (name, data, mime), + ) + for name, data, mime in parts + ] + def delete(self, post_id: str) -> None: self._http.delete(f"/posts/{post_id}") diff --git a/tests/test_posts_files.py b/tests/test_posts_files.py new file mode 100644 index 0000000..a7850f5 --- /dev/null +++ b/tests/test_posts_files.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import io +import json + +import httpx +import respx + +from fopost import FileTooLargeError, Fopost +from tests.conftest import BASE_URL, POST_FIXTURE + +UPLOAD_URL = "https://storage.test.fopost.com/uploads/up_1?sig=abc" + +LIMITS = { + "maxFileBytes": 5368709120, + "source": "add_on", + "connectedAccounts": 2, + "largeUploadsEnabled": True, + "nextTier": None, + "multipartMaxFileBytes": 16, + "maxFilesPerUpload": 5, + "directUploadAvailable": True, + "allowedTypes": ["image/png", "video/mp4"], +} + +PRESIGN = { + "uploadId": "up_1", + "uploadUrl": UPLOAD_URL, + "method": "PUT", + "headers": {"Content-Type": "video/mp4"}, + "maxFileBytes": 5368709120, +} + +MEDIA = { + "id": "med_1", + "type": "video", + "name": "clip.mp4", + "url": "media/ws_1/med_1.mp4", + "size": 32, +} + + +@respx.mock +def test_create_sends_one_multipart_request_when_every_file_fits(client: Fopost) -> None: + respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS}) + ) + create = respx.post(f"{BASE_URL}/posts").mock( + return_value=httpx.Response(201, json=POST_FIXTURE) + ) + presign = respx.post(f"{BASE_URL}/media/presign") + + post = client.posts.create( + workspace_id="ws_1", + content="Hello", + accounts=["acc_1"], + files=[("chart.png", b"\x89PNG", "image/png"), io.BytesIO(b"\x00" * 16)], + ) + + assert post.id == "post_1" + assert not presign.called + request = create.calls.last.request + content_type = request.headers["content-type"] + assert content_type.startswith("multipart/form-data; boundary=") + raw = request.content + assert b'name="post"' in raw + assert b'name="files"; filename="chart.png"' in raw + assert b"Content-Type: image/png" in raw + assert b'name="files"; filename="upload"' in raw + start = raw.index(b'name="post"') + body_json = raw[start:].split(b"\r\n\r\n", 1)[1].split(b"\r\n--", 1)[0] + assert json.loads(body_json) == { + "workspace_id": "ws_1", + "status": "draft", + "content": [{"text": "Hello"}], + "accounts": ["acc_1"], + } + + +@respx.mock +def test_create_uploads_directly_and_attaches_media_when_a_file_is_too_big( + client: Fopost, +) -> None: + respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS}) + ) + presign = respx.post(f"{BASE_URL}/media/presign").mock( + return_value=httpx.Response(201, json={"data": PRESIGN}) + ) + put = respx.put(UPLOAD_URL).mock(return_value=httpx.Response(200)) + respx.post(f"{BASE_URL}/media/presign/up_1/complete").mock( + return_value=httpx.Response(201, json={"data": MEDIA}) + ) + create = respx.post(f"{BASE_URL}/posts").mock( + return_value=httpx.Response(201, json=POST_FIXTURE) + ) + + client.posts.create( + workspace_id="ws_1", + content="Hello", + accounts=["acc_1"], + files=[("clip.mp4", b"\x00" * 32, "video/mp4")], + ) + + assert json.loads(presign.calls.last.request.content) == { + "workspaceId": "ws_1", + "filename": "clip.mp4", + "mimeType": "video/mp4", + "size": 32, + } + assert put.calls.last.request.content == b"\x00" * 32 + request = create.calls.last.request + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == { + "workspace_id": "ws_1", + "status": "draft", + "content": [ + { + "text": "Hello", + "media": [ + { + "type": "video", + "name": "clip.mp4", + "url": "media/ws_1/med_1.mp4", + "size": 32, + } + ], + } + ], + "accounts": ["acc_1"], + } + + +@respx.mock +def test_create_falls_back_to_the_default_ceiling_when_limits_fail(client: Fopost) -> None: + respx.get(f"{BASE_URL}/media/limits").mock(return_value=httpx.Response(500)) + create = respx.post(f"{BASE_URL}/posts").mock( + return_value=httpx.Response(201, json=POST_FIXTURE) + ) + + client.posts.create( + workspace_id="ws_1", content="Hello", files=[("clip.mp4", b"\x00" * 32, "video/mp4")] + ) + + assert create.calls.last.request.headers["content-type"].startswith("multipart/form-data") + + +@respx.mock +def test_update_without_content_appends_to_the_existing_first_block(client: Fopost) -> None: + respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS}) + ) + respx.get(f"{BASE_URL}/posts/post_1").mock(return_value=httpx.Response(200, json=POST_FIXTURE)) + respx.post(f"{BASE_URL}/media/presign").mock( + return_value=httpx.Response(201, json={"data": PRESIGN}) + ) + respx.put(UPLOAD_URL).mock(return_value=httpx.Response(200)) + respx.post(f"{BASE_URL}/media/presign/up_1/complete").mock( + return_value=httpx.Response(201, json={"data": MEDIA}) + ) + update = respx.put(f"{BASE_URL}/posts/post_1").mock( + return_value=httpx.Response(200, json=POST_FIXTURE) + ) + + client.posts.update("post_1", files=[("clip.mp4", b"\x00" * 32, "video/mp4")]) + + body = json.loads(update.calls.last.request.content) + assert body["content"][0]["text"] == "Hello from Python" + assert body["content"][0]["media"] == [ + {"type": "video", "name": "clip.mp4", "url": "media/ws_1/med_1.mp4", "size": 32} + ] + + +@respx.mock +def test_update_with_small_files_is_one_multipart_put(client: Fopost) -> None: + respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS}) + ) + get = respx.get(f"{BASE_URL}/posts/post_1") + update = respx.put(f"{BASE_URL}/posts/post_1").mock( + return_value=httpx.Response(200, json=POST_FIXTURE) + ) + + client.posts.update("post_1", title="Renamed", files=[("a.png", b"\x89PNG", "image/png")]) + + assert not get.called + raw = update.calls.last.request.content + assert b'name="files"; filename="a.png"' in raw + assert b'"title": "Renamed"' in raw + + +@respx.mock +def test_a_413_from_the_post_endpoint_is_file_too_large(client: Fopost) -> None: + respx.get(f"{BASE_URL}/media/limits").mock( + return_value=httpx.Response(200, json={"data": LIMITS}) + ) + respx.post(f"{BASE_URL}/posts").mock( + return_value=httpx.Response( + 413, + json={ + "error": "file_too_large", + "message": "Too big.", + "max_file_bytes": 104857600, + "file_bytes": 4, + "upgrade_url": "/settings/billing", + }, + ) + ) + + try: + client.posts.create( + workspace_id="ws_1", content="x", files=[("a.png", b"\x89PNG", "image/png")] + ) + except FileTooLargeError as error: + assert error.status == 413 + assert error.max_file_bytes == 104857600 + else: + raise AssertionError("expected FileTooLargeError") From 745477dcf74adf370ad7ebf7ed4556609f936f08 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Tue, 13 Oct 2026 23:33:55 +0200 Subject: [PATCH 5/5] docs: document upload limits and files on posts --- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.md | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b526730 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 959d110..fd9f051 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` |