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.
@@ -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)
@@ -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:]}"
@@ -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")}