typefully-cli 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.
- typefully_cli/__init__.py +3 -0
- typefully_cli/__main__.py +5 -0
- typefully_cli/auth.py +57 -0
- typefully_cli/batch.py +126 -0
- typefully_cli/cli.py +876 -0
- typefully_cli/client.py +267 -0
- typefully_cli/config.py +104 -0
- typefully_cli/console.py +56 -0
- typefully_cli/exceptions.py +80 -0
- typefully_cli/media.py +70 -0
- typefully_cli/output.py +188 -0
- typefully_cli-0.1.0.dist-info/METADATA +263 -0
- typefully_cli-0.1.0.dist-info/RECORD +16 -0
- typefully_cli-0.1.0.dist-info/WHEEL +4 -0
- typefully_cli-0.1.0.dist-info/entry_points.txt +2 -0
- typefully_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
typefully_cli/client.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Typefully API client. Thin HTTP wrapper over the v2 API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from typefully_cli.exceptions import AccountError, APIError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TypefullyClient:
|
|
13
|
+
BASE_URL = "https://api.typefully.com/v2"
|
|
14
|
+
RATE_LIMIT_DELAY = 0.3
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_key: str, base_url: str | None = None):
|
|
17
|
+
self._client = httpx.Client(
|
|
18
|
+
base_url=base_url or self.BASE_URL,
|
|
19
|
+
headers={
|
|
20
|
+
"Authorization": f"Bearer {api_key}",
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
timeout=30.0,
|
|
24
|
+
follow_redirects=True,
|
|
25
|
+
)
|
|
26
|
+
self._social_sets_cache: list[dict] | None = None
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
self._client.close()
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> TypefullyClient:
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, *args: object) -> None:
|
|
35
|
+
self.close()
|
|
36
|
+
|
|
37
|
+
# --- Core request ---
|
|
38
|
+
|
|
39
|
+
def _request(self, method: str, path: str, **kwargs: object) -> dict | None:
|
|
40
|
+
try:
|
|
41
|
+
resp = self._client.request(method, path, **kwargs)
|
|
42
|
+
except httpx.TimeoutException:
|
|
43
|
+
raise APIError(0, "Request timed out")
|
|
44
|
+
except httpx.HTTPError as e:
|
|
45
|
+
raise APIError(0, f"Network error: {e}")
|
|
46
|
+
if resp.status_code == 204:
|
|
47
|
+
return None
|
|
48
|
+
if not resp.is_success:
|
|
49
|
+
raise APIError(resp.status_code, resp.text)
|
|
50
|
+
try:
|
|
51
|
+
return resp.json()
|
|
52
|
+
except (ValueError, KeyError):
|
|
53
|
+
raise APIError(resp.status_code, "Invalid JSON response")
|
|
54
|
+
|
|
55
|
+
# --- Account resolution ---
|
|
56
|
+
|
|
57
|
+
def get_social_sets(self) -> list[dict]:
|
|
58
|
+
if self._social_sets_cache is not None:
|
|
59
|
+
return self._social_sets_cache
|
|
60
|
+
data = self._request("GET", "/social-sets", params={"limit": 50})
|
|
61
|
+
self._social_sets_cache = data.get("results", []) if data else []
|
|
62
|
+
return self._social_sets_cache
|
|
63
|
+
|
|
64
|
+
def resolve_account(self, name_or_id: str | None, config_default: str = "") -> int:
|
|
65
|
+
"""Resolve account to social_set_id.
|
|
66
|
+
|
|
67
|
+
Priority: name_or_id arg > config_default.
|
|
68
|
+
Matches: exact numeric ID > case-insensitive username > case-insensitive name.
|
|
69
|
+
"""
|
|
70
|
+
value = name_or_id or config_default
|
|
71
|
+
if not value:
|
|
72
|
+
raise AccountError()
|
|
73
|
+
|
|
74
|
+
sets = self.get_social_sets()
|
|
75
|
+
if not sets:
|
|
76
|
+
raise AccountError(
|
|
77
|
+
"No social sets found for this API key",
|
|
78
|
+
hint="Check your Typefully account has connected social profiles",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Try exact numeric ID
|
|
82
|
+
try:
|
|
83
|
+
numeric_id = int(value)
|
|
84
|
+
for s in sets:
|
|
85
|
+
if s.get("id") == numeric_id:
|
|
86
|
+
return numeric_id
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Case-insensitive username match
|
|
91
|
+
lower = value.lower()
|
|
92
|
+
for s in sets:
|
|
93
|
+
if s.get("username", "").lower() == lower:
|
|
94
|
+
return s["id"]
|
|
95
|
+
|
|
96
|
+
# Case-insensitive name match
|
|
97
|
+
for s in sets:
|
|
98
|
+
if s.get("name", "").lower() == lower:
|
|
99
|
+
return s["id"]
|
|
100
|
+
|
|
101
|
+
available = ", ".join(
|
|
102
|
+
s.get("username") or s.get("name", str(s.get("id", "?"))) for s in sets
|
|
103
|
+
)
|
|
104
|
+
raise AccountError(
|
|
105
|
+
f"Account '{value}' not found",
|
|
106
|
+
hint=f"Available accounts: {available}",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# --- User ---
|
|
110
|
+
|
|
111
|
+
def me(self) -> dict:
|
|
112
|
+
return self._request("GET", "/me") or {}
|
|
113
|
+
|
|
114
|
+
# --- Social sets ---
|
|
115
|
+
|
|
116
|
+
def get_social_set(self, social_set_id: int) -> dict:
|
|
117
|
+
return self._request("GET", f"/social-sets/{social_set_id}") or {}
|
|
118
|
+
|
|
119
|
+
# --- Drafts ---
|
|
120
|
+
|
|
121
|
+
def create_draft(self, social_set_id: int, payload: dict) -> dict:
|
|
122
|
+
return self._request("POST", f"/social-sets/{social_set_id}/drafts", json=payload) or {}
|
|
123
|
+
|
|
124
|
+
def get_draft(self, social_set_id: int, draft_id: str) -> dict:
|
|
125
|
+
return self._request("GET", f"/social-sets/{social_set_id}/drafts/{draft_id}") or {}
|
|
126
|
+
|
|
127
|
+
def update_draft(self, social_set_id: int, draft_id: str, payload: dict) -> dict:
|
|
128
|
+
return (
|
|
129
|
+
self._request("PATCH", f"/social-sets/{social_set_id}/drafts/{draft_id}", json=payload)
|
|
130
|
+
or {}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def delete_draft(self, social_set_id: int, draft_id: str) -> None:
|
|
134
|
+
self._request("DELETE", f"/social-sets/{social_set_id}/drafts/{draft_id}")
|
|
135
|
+
|
|
136
|
+
def list_drafts(self, social_set_id: int, **params: object) -> dict:
|
|
137
|
+
return (
|
|
138
|
+
self._request("GET", f"/social-sets/{social_set_id}/drafts", params=params) or {}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# --- Tags ---
|
|
142
|
+
|
|
143
|
+
def list_tags(self, social_set_id: int, limit: int = 50, offset: int = 0) -> dict:
|
|
144
|
+
return (
|
|
145
|
+
self._request(
|
|
146
|
+
"GET",
|
|
147
|
+
f"/social-sets/{social_set_id}/tags",
|
|
148
|
+
params={"limit": limit, "offset": offset},
|
|
149
|
+
)
|
|
150
|
+
or {}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def create_tag(self, social_set_id: int, name: str) -> dict:
|
|
154
|
+
return (
|
|
155
|
+
self._request("POST", f"/social-sets/{social_set_id}/tags", json={"name": name}) or {}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def ensure_tags(self, social_set_id: int, tag_names: list[str]) -> list[str]:
|
|
159
|
+
"""Create any tags that don't exist yet. Returns list of warnings."""
|
|
160
|
+
if not tag_names:
|
|
161
|
+
return []
|
|
162
|
+
warnings: list[str] = []
|
|
163
|
+
existing_names: set[str] = set()
|
|
164
|
+
offset = 0
|
|
165
|
+
while True:
|
|
166
|
+
page = self.list_tags(social_set_id, limit=50, offset=offset)
|
|
167
|
+
results = page.get("results", [])
|
|
168
|
+
existing_names.update(t.get("name", "").lower() for t in results)
|
|
169
|
+
if len(results) < 50:
|
|
170
|
+
break
|
|
171
|
+
offset += 50
|
|
172
|
+
for name in tag_names:
|
|
173
|
+
if name.lower() not in existing_names:
|
|
174
|
+
try:
|
|
175
|
+
self.create_tag(social_set_id, name)
|
|
176
|
+
except APIError:
|
|
177
|
+
warnings.append(f"Could not create tag '{name}'")
|
|
178
|
+
return warnings
|
|
179
|
+
|
|
180
|
+
# --- Media ---
|
|
181
|
+
|
|
182
|
+
def request_upload(self, social_set_id: int, file_name: str) -> dict:
|
|
183
|
+
return (
|
|
184
|
+
self._request(
|
|
185
|
+
"POST",
|
|
186
|
+
f"/social-sets/{social_set_id}/media/upload",
|
|
187
|
+
json={"file_name": file_name},
|
|
188
|
+
)
|
|
189
|
+
or {}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def upload_to_s3(self, upload_url: str, file_path: str) -> None:
|
|
193
|
+
"""Upload file to S3 presigned URL. No Content-Type header (breaks signature)."""
|
|
194
|
+
with open(file_path, "rb") as f:
|
|
195
|
+
resp = httpx.put(upload_url, content=f.read(), timeout=120.0)
|
|
196
|
+
if not resp.is_success:
|
|
197
|
+
raise APIError(resp.status_code, "S3 upload failed")
|
|
198
|
+
|
|
199
|
+
def poll_media(
|
|
200
|
+
self,
|
|
201
|
+
social_set_id: int,
|
|
202
|
+
media_id: str,
|
|
203
|
+
interval: float = 2.0,
|
|
204
|
+
max_attempts: int = 30,
|
|
205
|
+
) -> dict:
|
|
206
|
+
"""Poll media status until ready or timeout."""
|
|
207
|
+
for _ in range(max_attempts):
|
|
208
|
+
time.sleep(interval)
|
|
209
|
+
data = self._request("GET", f"/social-sets/{social_set_id}/media/{media_id}") or {}
|
|
210
|
+
status = data.get("status", "unknown")
|
|
211
|
+
if status in ("ready", "completed"):
|
|
212
|
+
return data
|
|
213
|
+
return data # return last status even if not ready
|
|
214
|
+
|
|
215
|
+
# --- Recent (with pagination) ---
|
|
216
|
+
|
|
217
|
+
def list_recent(
|
|
218
|
+
self,
|
|
219
|
+
social_set_id: int,
|
|
220
|
+
limit: int = 3,
|
|
221
|
+
since: str = "",
|
|
222
|
+
until: str = "",
|
|
223
|
+
) -> list[dict]:
|
|
224
|
+
"""List recently published posts. Paginates if date range given."""
|
|
225
|
+
if not since and not until:
|
|
226
|
+
data = self.list_drafts(
|
|
227
|
+
social_set_id,
|
|
228
|
+
status="published",
|
|
229
|
+
order_by="-published_at",
|
|
230
|
+
limit=limit,
|
|
231
|
+
)
|
|
232
|
+
return data.get("results", [])
|
|
233
|
+
|
|
234
|
+
# Paginate through date range
|
|
235
|
+
results: list[dict] = []
|
|
236
|
+
offset = 0
|
|
237
|
+
page_size = 50
|
|
238
|
+
while True:
|
|
239
|
+
data = self.list_drafts(
|
|
240
|
+
social_set_id,
|
|
241
|
+
status="published",
|
|
242
|
+
order_by="-published_at",
|
|
243
|
+
limit=page_size,
|
|
244
|
+
offset=offset,
|
|
245
|
+
)
|
|
246
|
+
page = data.get("results", [])
|
|
247
|
+
if not page:
|
|
248
|
+
break
|
|
249
|
+
for d in page:
|
|
250
|
+
raw_pub = d.get("published_at")
|
|
251
|
+
if not raw_pub:
|
|
252
|
+
continue
|
|
253
|
+
pub = raw_pub[:10]
|
|
254
|
+
if until and pub > until:
|
|
255
|
+
continue
|
|
256
|
+
if since and pub < since:
|
|
257
|
+
return results
|
|
258
|
+
results.append(d)
|
|
259
|
+
if len(page) < page_size:
|
|
260
|
+
break
|
|
261
|
+
offset += page_size
|
|
262
|
+
return results
|
|
263
|
+
|
|
264
|
+
# --- Rate-limited batch operations ---
|
|
265
|
+
|
|
266
|
+
def rate_delay(self) -> None:
|
|
267
|
+
time.sleep(self.RATE_LIMIT_DELAY)
|
typefully_cli/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Config file management: ~/.config/typefully/config.toml"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
try:
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError:
|
|
17
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _config_path() -> Path:
|
|
21
|
+
"""Return the config file path. Respects XDG_CONFIG_HOME."""
|
|
22
|
+
base = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
|
23
|
+
return Path(base) / "typefully" / "config.toml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Config:
|
|
28
|
+
api_key: str = ""
|
|
29
|
+
onepassword_item: str = ""
|
|
30
|
+
default_account: str = ""
|
|
31
|
+
_path: Path = field(default_factory=_config_path)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
35
|
+
p = path or _config_path()
|
|
36
|
+
cfg = cls(_path=p)
|
|
37
|
+
if not p.exists():
|
|
38
|
+
return cfg
|
|
39
|
+
with open(p, "rb") as f:
|
|
40
|
+
data = tomllib.load(f)
|
|
41
|
+
auth = data.get("auth", {})
|
|
42
|
+
cfg.api_key = auth.get("api_key", "")
|
|
43
|
+
cfg.onepassword_item = auth.get("onepassword_item", "")
|
|
44
|
+
defaults = data.get("defaults", {})
|
|
45
|
+
cfg.default_account = defaults.get("account", "")
|
|
46
|
+
return cfg
|
|
47
|
+
|
|
48
|
+
def save(self) -> None:
|
|
49
|
+
"""Write config back to TOML. Minimal hand-written TOML (no dependency)."""
|
|
50
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
lines: list[str] = []
|
|
52
|
+
lines.append("[auth]")
|
|
53
|
+
if self.api_key:
|
|
54
|
+
lines.append(f'api_key = "{self.api_key}"')
|
|
55
|
+
if self.onepassword_item:
|
|
56
|
+
lines.append(f'onepassword_item = "{self.onepassword_item}"')
|
|
57
|
+
lines.append("")
|
|
58
|
+
lines.append("[defaults]")
|
|
59
|
+
if self.default_account:
|
|
60
|
+
lines.append(f'account = "{self.default_account}"')
|
|
61
|
+
lines.append("")
|
|
62
|
+
self._path.write_text("\n".join(lines))
|
|
63
|
+
|
|
64
|
+
def to_dict(self, *, redact: bool = True) -> dict[str, Any]:
|
|
65
|
+
d: dict[str, Any] = {
|
|
66
|
+
"auth": {
|
|
67
|
+
"api_key": _redact(self.api_key) if redact else self.api_key,
|
|
68
|
+
},
|
|
69
|
+
"defaults": {},
|
|
70
|
+
}
|
|
71
|
+
if self.onepassword_item:
|
|
72
|
+
d["auth"]["onepassword_item"] = self.onepassword_item
|
|
73
|
+
if self.default_account:
|
|
74
|
+
d["defaults"]["account"] = self.default_account
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- Config set/show helpers ---
|
|
79
|
+
|
|
80
|
+
_VALID_KEYS = {"api_key", "default_account", "onepassword_item"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def config_set(cfg: Config, key: str, value: str) -> dict:
|
|
84
|
+
"""Set a config key. Returns the result dict (api_key redacted)."""
|
|
85
|
+
if key not in _VALID_KEYS:
|
|
86
|
+
raise ValueError(f"Unknown config key: {key}. Valid: {', '.join(sorted(_VALID_KEYS))}")
|
|
87
|
+
if key == "api_key":
|
|
88
|
+
cfg.api_key = value
|
|
89
|
+
elif key == "default_account":
|
|
90
|
+
cfg.default_account = value
|
|
91
|
+
elif key == "onepassword_item":
|
|
92
|
+
cfg.onepassword_item = value
|
|
93
|
+
cfg.save()
|
|
94
|
+
display_value = _redact(value) if key == "api_key" else value
|
|
95
|
+
return {"key": key, "value": display_value}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _redact(key: str) -> str:
|
|
99
|
+
"""Redact an API key: show first 3 and last 4 chars."""
|
|
100
|
+
if not key:
|
|
101
|
+
return ""
|
|
102
|
+
if len(key) <= 7:
|
|
103
|
+
return "***"
|
|
104
|
+
return f"{key[:3]}...{key[-4:]}"
|
typefully_cli/console.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Centralized stderr output for status, progress, and warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console as RichConsole
|
|
10
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Console:
|
|
14
|
+
"""All non-data output goes through here, always to stderr.
|
|
15
|
+
|
|
16
|
+
--quiet suppresses status/progress/warning but NOT error JSON.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, quiet: bool = False):
|
|
20
|
+
self._quiet = quiet
|
|
21
|
+
self._rich = RichConsole(stderr=True)
|
|
22
|
+
|
|
23
|
+
def status(self, msg: str) -> None:
|
|
24
|
+
if not self._quiet:
|
|
25
|
+
self._rich.print(f"[dim]{msg}[/dim]")
|
|
26
|
+
|
|
27
|
+
def success(self, msg: str) -> None:
|
|
28
|
+
if not self._quiet:
|
|
29
|
+
self._rich.print(f"[green]{msg}[/green]")
|
|
30
|
+
|
|
31
|
+
def warning(self, msg: str) -> None:
|
|
32
|
+
if not self._quiet:
|
|
33
|
+
self._rich.print(f"[yellow]Warning: {msg}[/yellow]")
|
|
34
|
+
|
|
35
|
+
def error_json(self, error_dict: dict) -> None:
|
|
36
|
+
"""Write structured error JSON as the last line of stderr. Never suppressed."""
|
|
37
|
+
envelope = {"ok": False, "error": error_dict}
|
|
38
|
+
sys.stderr.write(json.dumps(envelope) + "\n")
|
|
39
|
+
sys.stderr.flush()
|
|
40
|
+
|
|
41
|
+
def progress(self, description: str = "Processing...") -> Progress:
|
|
42
|
+
return Progress(
|
|
43
|
+
SpinnerColumn(),
|
|
44
|
+
TextColumn("[progress.description]{task.description}"),
|
|
45
|
+
BarColumn(),
|
|
46
|
+
TimeElapsedColumn(),
|
|
47
|
+
console=self._rich,
|
|
48
|
+
disable=self._quiet,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_success(data: Any) -> None:
|
|
53
|
+
"""Write success JSON envelope to stdout."""
|
|
54
|
+
envelope = {"ok": True, "data": data}
|
|
55
|
+
sys.stdout.write(json.dumps(envelope, default=str) + "\n")
|
|
56
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Exception hierarchy for Typefully CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TypefullyError(Exception):
|
|
7
|
+
"""Base exception for all Typefully CLI errors."""
|
|
8
|
+
|
|
9
|
+
code: str = "unknown"
|
|
10
|
+
hint: str = ""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, *, code: str | None = None, hint: str = ""):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
if code is not None:
|
|
15
|
+
self.code = code
|
|
16
|
+
if hint:
|
|
17
|
+
self.hint = hint
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
d: dict = {"code": self.code, "message": str(self)}
|
|
21
|
+
if self.hint:
|
|
22
|
+
d["hint"] = self.hint
|
|
23
|
+
return d
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthError(TypefullyError):
|
|
27
|
+
"""Authentication or credential error."""
|
|
28
|
+
|
|
29
|
+
code = "auth_failed"
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str = "No API key found", *, hint: str = ""):
|
|
32
|
+
if not hint:
|
|
33
|
+
hint = (
|
|
34
|
+
"Set TYPEFULLY_API_KEY, pass --api-key, "
|
|
35
|
+
"or run: typefully config set api_key <key>"
|
|
36
|
+
)
|
|
37
|
+
super().__init__(message, hint=hint)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APIError(TypefullyError):
|
|
41
|
+
"""HTTP error from the Typefully API."""
|
|
42
|
+
|
|
43
|
+
code = "api_error"
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int, body: str, *, hint: str = ""):
|
|
46
|
+
self.status_code = status_code
|
|
47
|
+
super().__init__(f"API returned {status_code}: {body[:200]}", hint=hint)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def user_message(self) -> str:
|
|
51
|
+
"""Clean message without raw upstream body, safe for stdout."""
|
|
52
|
+
return f"API error (HTTP {self.status_code})"
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
d = super().to_dict()
|
|
56
|
+
d["status"] = self.status_code
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AccountError(TypefullyError):
|
|
61
|
+
"""Account resolution error."""
|
|
62
|
+
|
|
63
|
+
code = "no_account"
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str = "No account specified", *, hint: str = ""):
|
|
66
|
+
if not hint:
|
|
67
|
+
hint = "Pass --account NAME or run: typefully config set default_account NAME"
|
|
68
|
+
super().__init__(message, hint=hint)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BatchParseError(TypefullyError):
|
|
72
|
+
"""Error parsing a batch file."""
|
|
73
|
+
|
|
74
|
+
code = "batch_parse_error"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MediaUploadError(TypefullyError):
|
|
78
|
+
"""Error during media upload."""
|
|
79
|
+
|
|
80
|
+
code = "media_upload_error"
|
typefully_cli/media.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Media upload: presigned URL + S3 PUT + poll for ready."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from typefully_cli.client import TypefullyClient
|
|
9
|
+
from typefully_cli.console import Console
|
|
10
|
+
from typefully_cli.exceptions import MediaUploadError
|
|
11
|
+
|
|
12
|
+
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "gif", "mp4", "mov", "pdf"}
|
|
13
|
+
SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.()\-]")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sanitize_filename(name: str) -> str:
|
|
17
|
+
"""Sanitize filename to match Typefully's allowed charset.
|
|
18
|
+
|
|
19
|
+
Replaces disallowed characters with underscore. Validates extension.
|
|
20
|
+
"""
|
|
21
|
+
stem, _, ext = name.rpartition(".")
|
|
22
|
+
if not ext or ext.lower() not in ALLOWED_EXTENSIONS:
|
|
23
|
+
raise MediaUploadError(
|
|
24
|
+
f"Unsupported file type: .{ext}" if ext else "No file extension",
|
|
25
|
+
hint=f"Supported: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
|
26
|
+
)
|
|
27
|
+
safe_stem = SAFE_NAME_RE.sub("_", stem) or "upload"
|
|
28
|
+
return f"{safe_stem}.{ext}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upload_media(
|
|
32
|
+
client: TypefullyClient,
|
|
33
|
+
social_set_id: int,
|
|
34
|
+
file_path: str,
|
|
35
|
+
console: Console,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Upload a media file and poll until ready. Returns {media_id, status}."""
|
|
38
|
+
if not os.path.isfile(file_path):
|
|
39
|
+
raise MediaUploadError(f"File not found: {file_path}")
|
|
40
|
+
|
|
41
|
+
file_name = _sanitize_filename(os.path.basename(file_path))
|
|
42
|
+
|
|
43
|
+
# Step 1: Get presigned upload URL
|
|
44
|
+
console.status(f"Requesting upload URL for {file_name}...")
|
|
45
|
+
upload_response = client.request_upload(social_set_id, file_name)
|
|
46
|
+
media_id = upload_response.get("media_id", "")
|
|
47
|
+
upload_url = upload_response.get("upload_url", "")
|
|
48
|
+
if not media_id or not upload_url:
|
|
49
|
+
raise MediaUploadError("API did not return media_id or upload_url")
|
|
50
|
+
|
|
51
|
+
# Step 2: Upload to S3
|
|
52
|
+
console.status(f"Uploading {file_name}...")
|
|
53
|
+
client.upload_to_s3(upload_url, file_path)
|
|
54
|
+
|
|
55
|
+
# Step 3: Poll for processing
|
|
56
|
+
console.status("Processing...")
|
|
57
|
+
with console.progress("Processing media...") as progress:
|
|
58
|
+
task = progress.add_task("Processing...", total=30)
|
|
59
|
+
data = {}
|
|
60
|
+
for attempt in range(30):
|
|
61
|
+
import time
|
|
62
|
+
|
|
63
|
+
time.sleep(2)
|
|
64
|
+
progress.advance(task)
|
|
65
|
+
data = client._request("GET", f"/social-sets/{social_set_id}/media/{media_id}") or {}
|
|
66
|
+
status = data.get("status", "unknown")
|
|
67
|
+
if status in ("ready", "completed"):
|
|
68
|
+
return {"media_id": media_id, "status": "ready"}
|
|
69
|
+
|
|
70
|
+
return {"media_id": media_id, "status": data.get("status", "unknown")}
|