fopost 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fopost/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """fopost — official Python SDK for the FoPost API.
2
+
3
+ Quick start::
4
+
5
+ from fopost import Fopost
6
+
7
+ client = Fopost(api_key="osk_...")
8
+
9
+ accounts = client.accounts.list(workspace_id="9b2f6c1e-...")
10
+ post = client.posts.create(
11
+ workspace_id="9b2f6c1e-...",
12
+ content="Hello from Python",
13
+ accounts=[a.id for a in accounts],
14
+ )
15
+ client.posts.publish(post.id)
16
+ """
17
+
18
+ from ._http import DEFAULT_BASE_URL
19
+ from .client import Fopost
20
+ from .errors import (
21
+ AuthenticationError,
22
+ NotFoundError,
23
+ FopostError,
24
+ PaymentRequiredError,
25
+ PermissionDeniedError,
26
+ RateLimitError,
27
+ )
28
+ from .models import (
29
+ PLATFORMS,
30
+ POST_STATUSES,
31
+ AiCreditBalance,
32
+ AiCredits,
33
+ CaptionResult,
34
+ ContentBlock,
35
+ Delivery,
36
+ Label,
37
+ MediaItem,
38
+ Page,
39
+ PageMeta,
40
+ Platform,
41
+ Post,
42
+ PostAccount,
43
+ PostStatus,
44
+ RepurposeResult,
45
+ RewriteResult,
46
+ RewriteVariant,
47
+ SocialAccount,
48
+ Workspace,
49
+ )
50
+
51
+ __version__ = "0.1.0"
52
+
53
+ #: Alias for people arriving from the TypeScript SDK, where the class is `FoPost`.
54
+ FoPost = Fopost
55
+
56
+ __all__ = [
57
+ "DEFAULT_BASE_URL",
58
+ "PLATFORMS",
59
+ "POST_STATUSES",
60
+ "AiCreditBalance",
61
+ "AiCredits",
62
+ "AuthenticationError",
63
+ "CaptionResult",
64
+ "ContentBlock",
65
+ "Delivery",
66
+ "Label",
67
+ "MediaItem",
68
+ "NotFoundError",
69
+ "FoPost",
70
+ "Fopost",
71
+ "FopostError",
72
+ "Page",
73
+ "PageMeta",
74
+ "PaymentRequiredError",
75
+ "PermissionDeniedError",
76
+ "Platform",
77
+ "Post",
78
+ "PostAccount",
79
+ "PostStatus",
80
+ "RateLimitError",
81
+ "RepurposeResult",
82
+ "RewriteResult",
83
+ "RewriteVariant",
84
+ "SocialAccount",
85
+ "Workspace",
86
+ "__version__",
87
+ ]
fopost/_http.py ADDED
@@ -0,0 +1,185 @@
1
+ """Internal HTTP transport: auth headers, JSON coding, envelope unwrap, retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+
12
+ from .errors import FopostError, RateLimitError, error_from_response
13
+
14
+ DEFAULT_BASE_URL = "https://api.fopost.com/api/v1"
15
+ DEFAULT_TIMEOUT = 30.0
16
+ DEFAULT_MAX_RETRIES = 3
17
+ MAX_RETRY_WAIT = 60.0
18
+
19
+ USER_AGENT = "fopost-python"
20
+
21
+ # Indirected so tests can replace the wait without touching the real clock.
22
+ _sleep = time.sleep
23
+
24
+
25
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
26
+ """Parse Retry-After, which is either delta-seconds or an HTTP date."""
27
+ raw = response.headers.get("retry-after")
28
+ if not raw:
29
+ return None
30
+ raw = raw.strip()
31
+ try:
32
+ return max(0.0, float(raw))
33
+ except ValueError:
34
+ pass
35
+ try:
36
+ target = parsedate_to_datetime(raw)
37
+ except (TypeError, ValueError):
38
+ return None
39
+ if target is None:
40
+ return None
41
+ delta = target.timestamp() - time.time()
42
+ return max(0.0, delta)
43
+
44
+
45
+ class HttpClient:
46
+ """Thin httpx wrapper. One per ``Fopost`` instance."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ api_key: str,
52
+ base_url: str = DEFAULT_BASE_URL,
53
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
54
+ max_retries: int = DEFAULT_MAX_RETRIES,
55
+ client: httpx.Client | None = None,
56
+ ) -> None:
57
+ if not api_key:
58
+ raise ValueError("fopost: api_key is required")
59
+ if max_retries < 1:
60
+ raise ValueError("fopost: max_retries must be at least 1")
61
+
62
+ self.api_key = api_key
63
+ self.base_url = base_url.rstrip("/")
64
+ self.max_retries = max_retries
65
+ self._owns_client = client is None
66
+ self._client = client or httpx.Client(timeout=timeout)
67
+
68
+ @property
69
+ def headers(self) -> dict[str, str]:
70
+ return {
71
+ "Accept": "application/json",
72
+ "Content-Type": "application/json",
73
+ "X-API-Key": self.api_key,
74
+ "User-Agent": USER_AGENT,
75
+ }
76
+
77
+ def _url(self, path: str) -> str:
78
+ if urlsplit(path).scheme:
79
+ return path
80
+ return f"{self.base_url}/{path.lstrip('/')}"
81
+
82
+ def request(
83
+ self,
84
+ method: str,
85
+ path: str,
86
+ *,
87
+ json: Any | None = None,
88
+ params: dict[str, Any] | None = None,
89
+ ) -> Any:
90
+ """Send a request, retrying on 429, and return the decoded body."""
91
+ clean_params = {k: v for k, v in params.items() if v is not None} if params else None
92
+
93
+ attempt = 0
94
+ while True:
95
+ attempt += 1
96
+ response = self._client.request(
97
+ method,
98
+ self._url(path),
99
+ json=json,
100
+ params=clean_params,
101
+ headers=self.headers,
102
+ )
103
+
104
+ if response.status_code == 429 and attempt < self.max_retries:
105
+ wait = _retry_after_seconds(response)
106
+ _sleep(min(wait if wait is not None else 1.0, MAX_RETRY_WAIT))
107
+ continue
108
+
109
+ return self._decode(response)
110
+
111
+ def _decode(self, response: httpx.Response) -> Any:
112
+ body: Any
113
+ if response.status_code == 204 or not response.content:
114
+ body = None
115
+ else:
116
+ try:
117
+ body = response.json()
118
+ except ValueError:
119
+ body = response.text
120
+
121
+ if response.is_success:
122
+ if isinstance(body, str):
123
+ content_type = response.headers.get("content-type", "")
124
+ raise FopostError(
125
+ f"Expected a JSON response, got {content_type or 'no content type'}",
126
+ status=response.status_code,
127
+ body=body,
128
+ )
129
+ return body
130
+
131
+ if response.status_code == 429:
132
+ raise RateLimitError(
133
+ _message_of(body, response.status_code),
134
+ status=429,
135
+ code=_code_of(body),
136
+ body=body,
137
+ retry_after=_retry_after_seconds(response),
138
+ )
139
+ raise error_from_response(response.status_code, body)
140
+
141
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
142
+ return self.request("GET", path, params=params)
143
+
144
+ def post(self, path: str, json: Any | None = None) -> Any:
145
+ return self.request("POST", path, json=json)
146
+
147
+ def put(self, path: str, json: Any | None = None) -> Any:
148
+ return self.request("PUT", path, json=json)
149
+
150
+ def delete(self, path: str, json: Any | None = None) -> Any:
151
+ return self.request("DELETE", path, json=json)
152
+
153
+ def close(self) -> None:
154
+ if self._owns_client:
155
+ self._client.close()
156
+
157
+
158
+ def _message_of(body: Any, status: int) -> str:
159
+ if isinstance(body, dict):
160
+ message = body.get("message")
161
+ if isinstance(message, str) and message:
162
+ return message
163
+ error = body.get("error")
164
+ if isinstance(error, str) and error:
165
+ return error
166
+ return f"HTTP {status}"
167
+
168
+
169
+ def _code_of(body: Any) -> str | None:
170
+ if isinstance(body, dict):
171
+ error = body.get("error")
172
+ if isinstance(error, str):
173
+ return error
174
+ return None
175
+
176
+
177
+ def unwrap(body: Any) -> Any:
178
+ """Peel the ``{"data": ...}`` envelope the API wraps most responses in.
179
+
180
+ Some endpoints (``POST /posts``, ``GET /posts/{id}``) return the resource
181
+ bare, so the envelope is unwrapped only when it is actually there.
182
+ """
183
+ if isinstance(body, dict) and "data" in body:
184
+ return body["data"]
185
+ return body
fopost/client.py ADDED
@@ -0,0 +1,94 @@
1
+ """The ``Fopost`` client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from ._http import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient
12
+ from .resources import (
13
+ AccountsResource,
14
+ AiResource,
15
+ LabelsResource,
16
+ PostsResource,
17
+ WorkspacesResource,
18
+ )
19
+
20
+ __all__ = ["Fopost"]
21
+
22
+
23
+ class Fopost:
24
+ """Client for the FoPost API.
25
+
26
+ ::
27
+
28
+ from fopost import Fopost
29
+
30
+ client = Fopost(api_key="osk_...")
31
+ accounts = client.accounts.list(workspace_id="9b2f6c1e-...")
32
+
33
+ The key falls back to the ``FOPOST_API_KEY`` environment variable.
34
+ Requests that come back 429 are retried up to ``max_retries`` attempts,
35
+ waiting for the interval the API asks for in ``Retry-After``.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: str | None = None,
41
+ *,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
44
+ max_retries: int = DEFAULT_MAX_RETRIES,
45
+ http_client: httpx.Client | None = None,
46
+ ) -> None:
47
+ key = api_key or os.environ.get("FOPOST_API_KEY")
48
+ if not key:
49
+ raise ValueError(
50
+ "fopost: an api_key is required — pass api_key=... or set FOPOST_API_KEY"
51
+ )
52
+
53
+ self._http = HttpClient(
54
+ api_key=key,
55
+ base_url=base_url,
56
+ timeout=timeout,
57
+ max_retries=max_retries,
58
+ client=http_client,
59
+ )
60
+
61
+ self.posts = PostsResource(self._http)
62
+ self.accounts = AccountsResource(self._http)
63
+ self.workspaces = WorkspacesResource(self._http)
64
+ self.labels = LabelsResource(self._http)
65
+ self.ai = AiResource(self._http)
66
+
67
+ @property
68
+ def base_url(self) -> str:
69
+ return self._http.base_url
70
+
71
+ def request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ json: Any | None = None,
77
+ params: dict[str, Any] | None = None,
78
+ ) -> Any:
79
+ """Call an endpoint the SDK does not wrap yet. Returns the decoded body."""
80
+ return self._http.request(method, path, json=json, params=params)
81
+
82
+ def close(self) -> None:
83
+ self._http.close()
84
+
85
+ def __enter__(self) -> Fopost:
86
+ return self
87
+
88
+ def __exit__(
89
+ self,
90
+ exc_type: type[BaseException] | None,
91
+ exc: BaseException | None,
92
+ tb: TracebackType | None,
93
+ ) -> None:
94
+ self.close()
fopost/errors.py ADDED
@@ -0,0 +1,122 @@
1
+ """Exceptions raised by the SDK.
2
+
3
+ Every non-2xx response becomes an ``FopostError``. The API answers with an
4
+ ``{"error": "<code>", "message": "<human readable>"}`` envelope, which maps onto
5
+ the ``code`` and ``message`` attributes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ __all__ = [
13
+ "FopostError",
14
+ "AuthenticationError",
15
+ "PaymentRequiredError",
16
+ "PermissionDeniedError",
17
+ "NotFoundError",
18
+ "RateLimitError",
19
+ "error_from_response",
20
+ ]
21
+
22
+
23
+ class FopostError(Exception):
24
+ """Base class for every error returned by the FoPost API."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status: int,
31
+ code: str | None = None,
32
+ body: Any = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status = status
37
+ self.code = code
38
+ self.body = body
39
+
40
+ def __str__(self) -> str:
41
+ suffix = f" ({self.code})" if self.code else ""
42
+ return f"[{self.status}{suffix}] {self.message}"
43
+
44
+
45
+ class AuthenticationError(FopostError):
46
+ """401 — missing, invalid, or expired API key."""
47
+
48
+
49
+ class PaymentRequiredError(FopostError):
50
+ """402 — no active subscription, or AI credits exhausted.
51
+
52
+ ``upgrade_url`` carries the path the API suggests sending the user to.
53
+ """
54
+
55
+ @property
56
+ def upgrade_url(self) -> str | None:
57
+ if isinstance(self.body, dict):
58
+ value = self.body.get("upgrade_url")
59
+ if isinstance(value, str):
60
+ return value
61
+ return None
62
+
63
+
64
+ class PermissionDeniedError(FopostError):
65
+ """403 — the key is valid but lacks the scope or workspace access."""
66
+
67
+
68
+ class NotFoundError(FopostError):
69
+ """404 — no such resource, or it is outside the key's reach."""
70
+
71
+
72
+ class RateLimitError(FopostError):
73
+ """429 — rate limit exceeded. ``retry_after`` is in seconds when sent."""
74
+
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ *,
79
+ status: int,
80
+ code: str | None = None,
81
+ body: Any = None,
82
+ retry_after: float | None = None,
83
+ ) -> None:
84
+ super().__init__(message, status=status, code=code, body=body)
85
+ self.retry_after = retry_after
86
+
87
+
88
+ _BY_STATUS: dict[int, type[FopostError]] = {
89
+ 401: AuthenticationError,
90
+ 402: PaymentRequiredError,
91
+ 403: PermissionDeniedError,
92
+ 404: NotFoundError,
93
+ 429: RateLimitError,
94
+ }
95
+
96
+
97
+ def error_from_response(
98
+ status: int,
99
+ body: Any,
100
+ *,
101
+ retry_after: float | None = None,
102
+ ) -> FopostError:
103
+ """Build the most specific error class for a failed response."""
104
+ code: str | None = None
105
+ message = f"HTTP {status}"
106
+
107
+ if isinstance(body, dict):
108
+ raw_error = body.get("error")
109
+ if isinstance(raw_error, str):
110
+ code = raw_error
111
+ raw_message = body.get("message")
112
+ if isinstance(raw_message, str) and raw_message:
113
+ message = raw_message
114
+ elif code:
115
+ message = code
116
+ elif isinstance(body, str) and body.strip():
117
+ message = body.strip()
118
+
119
+ cls = _BY_STATUS.get(status, FopostError)
120
+ if cls is RateLimitError:
121
+ return RateLimitError(message, status=status, code=code, body=body, retry_after=retry_after)
122
+ return cls(message, status=status, code=code, body=body)