python-substack 0.1.24__py3-none-any.whl → 0.1.26__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.
substack/cli.py ADDED
@@ -0,0 +1,583 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from urllib.parse import urljoin
8
+
9
+ import yaml
10
+ from dotenv import load_dotenv
11
+
12
+ from substack import Api, __version__
13
+ from substack.exceptions import SubstackAPIException, SubstackRequestException
14
+ from substack.post import Post
15
+
16
+
17
+ class CLIUsageError(Exception):
18
+ pass
19
+
20
+
21
+ def _api_from_env(cookies_path=None, publication_url=None):
22
+ load_dotenv()
23
+
24
+ cookies_path = cookies_path or os.getenv("COOKIES_PATH")
25
+ cookies_string = os.getenv("COOKIES_STRING")
26
+ publication_url = publication_url or os.getenv("PUBLICATION_URL")
27
+
28
+ if cookies_path or cookies_string:
29
+ return Api(
30
+ cookies_path=cookies_path,
31
+ cookies_string=cookies_string,
32
+ publication_url=publication_url,
33
+ )
34
+
35
+ return Api(
36
+ email=os.getenv("EMAIL"),
37
+ password=os.getenv("PASSWORD"),
38
+ publication_url=publication_url,
39
+ )
40
+
41
+
42
+ def _auth_method(cookies_path=None):
43
+ load_dotenv()
44
+
45
+ if cookies_path:
46
+ return "cookies_path"
47
+ if os.getenv("COOKIES_PATH"):
48
+ return "cookies_path"
49
+ if os.getenv("COOKIES_STRING"):
50
+ return "cookies_string"
51
+ if os.getenv("EMAIL") and os.getenv("PASSWORD"):
52
+ return "email_password"
53
+ return "unknown"
54
+
55
+
56
+ def _title_from_markdown(markdown, fallback):
57
+ for line in markdown.splitlines():
58
+ if line.startswith("# "):
59
+ return line[2:].strip()
60
+ return fallback
61
+
62
+
63
+ def _print_result(result):
64
+ draft = result["draft"]
65
+ print(json.dumps({"draft_id": draft.get("id"), "draft": draft}, indent=2))
66
+
67
+
68
+ def _print_json(value, stream=None):
69
+ print(json.dumps(value, indent=2, default=str), file=stream)
70
+
71
+
72
+ def _display(value):
73
+ if value is None or value == "":
74
+ return "-"
75
+ return str(value)
76
+
77
+
78
+ def _print_rows(headers, rows):
79
+ rows = [[_display(value) for value in row] for row in rows]
80
+ widths = [len(header) for header in headers]
81
+ for row in rows:
82
+ for index, value in enumerate(row):
83
+ widths[index] = max(widths[index], len(value))
84
+
85
+ print(
86
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
87
+ )
88
+ print(" ".join("-" * width for width in widths))
89
+ for row in rows:
90
+ print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
91
+
92
+
93
+ def _identity(profile):
94
+ return (
95
+ profile.get("email")
96
+ or profile.get("name")
97
+ or profile.get("handle")
98
+ or str(profile.get("id", "unknown"))
99
+ )
100
+
101
+
102
+ def _selected_publication(api, publications):
103
+ for publication in publications:
104
+ publication_url = publication.get("publication_url")
105
+ if (
106
+ publication_url
107
+ and urljoin(publication_url, "api/v1") == api.publication_url
108
+ ):
109
+ return publication
110
+ return {"publication_url": api.publication_url.removesuffix("/api/v1")}
111
+
112
+
113
+ def _parse_schedule(value):
114
+ normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
115
+ try:
116
+ scheduled_at = datetime.fromisoformat(normalized)
117
+ except ValueError as exc:
118
+ raise CLIUsageError("--at must be a valid ISO 8601 timestamp") from exc
119
+ if scheduled_at.utcoffset() is None:
120
+ raise CLIUsageError("--at must include a timezone offset or Z")
121
+ return scheduled_at
122
+
123
+
124
+ def _confirm(action, target, yes, json_output):
125
+ if yes:
126
+ return
127
+ if json_output or not sys.stdin.isatty():
128
+ raise CLIUsageError(f"{action} requires --yes in non-interactive or JSON mode")
129
+ try:
130
+ confirmed = input(f"{action} draft {target}? [y/N] ").strip().lower()
131
+ except EOFError as exc:
132
+ raise CLIUsageError(f"{action} requires confirmation or --yes") from exc
133
+ if confirmed not in {"y", "yes"}:
134
+ raise CLIUsageError("Cancelled")
135
+
136
+
137
+ def _redact(message):
138
+ redacted = str(message)
139
+ for name in ("EMAIL", "PASSWORD", "COOKIES_STRING"):
140
+ secret = os.getenv(name)
141
+ if secret:
142
+ redacted = redacted.replace(secret, "[redacted]")
143
+ return redacted
144
+
145
+
146
+ def _error_payload(exc):
147
+ if isinstance(exc, SubstackAPIException):
148
+ return {
149
+ "error": {
150
+ "type": "api_error",
151
+ "message": _redact(exc.message),
152
+ "status_code": exc.status_code,
153
+ }
154
+ }
155
+ if isinstance(exc, SubstackRequestException):
156
+ return {
157
+ "error": {
158
+ "type": "request_error",
159
+ "message": _redact(exc.message),
160
+ }
161
+ }
162
+ if isinstance(exc, OSError):
163
+ return {"error": {"type": "io_error", "message": _redact(exc)}}
164
+ return {"error": {"type": "configuration_error", "message": _redact(exc)}}
165
+
166
+
167
+ def _print_error(exc, json_output):
168
+ payload = _error_payload(exc)
169
+ if json_output:
170
+ _print_json(payload, stream=sys.stderr)
171
+ else:
172
+ print(f"Error: {payload['error']['message']}", file=sys.stderr)
173
+
174
+
175
+ def _print_usage_error(exc, json_output):
176
+ if json_output:
177
+ _print_json(
178
+ {"error": {"type": "usage_error", "message": str(exc)}},
179
+ stream=sys.stderr,
180
+ )
181
+ else:
182
+ print(f"Error: {exc}", file=sys.stderr)
183
+
184
+
185
+ def _status(api, args):
186
+ profile = api.get_user_profile()
187
+ publications = api.get_user_publications()
188
+ selected = _selected_publication(api, publications)
189
+ subscriber_count = api.get_publication_subscriber_count()
190
+ result = {
191
+ "status": {
192
+ "identity": _identity(profile),
193
+ "auth_method": _auth_method(args.cookies),
194
+ "subscriber_count": subscriber_count,
195
+ },
196
+ "profile": profile,
197
+ "publication": selected,
198
+ "publications": publications,
199
+ }
200
+ if args.json_output:
201
+ _print_json(result)
202
+ else:
203
+ print(f"Authenticated as: {result['status']['identity']}")
204
+ print(f"Auth method: {result['status']['auth_method']}")
205
+ print(f"Publication: {_display(selected.get('name'))}")
206
+ print(f"Publication URL: {_display(selected.get('publication_url'))}")
207
+ print(f"Subscribers: {subscriber_count}")
208
+ print(f"Available publications: {len(publications)}")
209
+
210
+
211
+ def _publications_list(api, args):
212
+ publications = api.get_user_publications()
213
+ if args.json_output:
214
+ _print_json({"publications": publications, "count": len(publications)})
215
+ else:
216
+ _print_rows(
217
+ ["ID", "NAME", "SUBDOMAIN", "URL"],
218
+ [
219
+ [
220
+ publication.get("id"),
221
+ publication.get("name"),
222
+ publication.get("subdomain"),
223
+ publication.get("publication_url"),
224
+ ]
225
+ for publication in publications
226
+ ],
227
+ )
228
+
229
+
230
+ def _drafts_list(api, args):
231
+ if args.offset < 0:
232
+ raise CLIUsageError("--offset must be zero or greater")
233
+ if args.limit < 1:
234
+ raise CLIUsageError("--limit must be greater than zero")
235
+ drafts = api.get_drafts(filter=args.filter, offset=args.offset, limit=args.limit)
236
+ if args.json_output:
237
+ _print_json(
238
+ {
239
+ "drafts": drafts,
240
+ "count": len(drafts),
241
+ "filter": args.filter,
242
+ "offset": args.offset,
243
+ "limit": args.limit,
244
+ }
245
+ )
246
+ else:
247
+ _print_rows(
248
+ ["ID", "TITLE", "STATUS", "SCHEDULED"],
249
+ [
250
+ [
251
+ draft.get("id"),
252
+ draft.get("draft_title") or draft.get("title"),
253
+ draft.get("type") or draft.get("status") or "draft",
254
+ draft.get("post_date"),
255
+ ]
256
+ for draft in drafts
257
+ ],
258
+ )
259
+
260
+
261
+ def _drafts_get(api, args):
262
+ draft = api.get_draft(args.draft_id)
263
+ if args.json_output:
264
+ _print_json({"draft": draft})
265
+ else:
266
+ fields = [
267
+ ("ID", draft.get("id")),
268
+ ("Title", draft.get("draft_title") or draft.get("title")),
269
+ ("Subtitle", draft.get("draft_subtitle") or draft.get("subtitle")),
270
+ ("Status", draft.get("type") or draft.get("status") or "draft"),
271
+ ("Slug", draft.get("slug")),
272
+ ("Scheduled", draft.get("post_date")),
273
+ ("Audience", draft.get("audience")),
274
+ ]
275
+ for label, value in fields:
276
+ print(f"{label}: {_display(value)}")
277
+
278
+
279
+ def _drafts_schedule(api, args):
280
+ scheduled_at = _parse_schedule(args.at)
281
+ result = api.schedule_draft(args.draft_id, scheduled_at)
282
+ payload = {
283
+ "action": "schedule",
284
+ "draft_id": args.draft_id,
285
+ "scheduled_at": scheduled_at.isoformat(),
286
+ "result": result,
287
+ }
288
+ if args.json_output:
289
+ _print_json(payload)
290
+ else:
291
+ print(f"Scheduled draft {args.draft_id} for {scheduled_at.isoformat()}")
292
+
293
+
294
+ def _drafts_unschedule(api, args):
295
+ result = api.unschedule_draft(args.draft_id)
296
+ payload = {"action": "unschedule", "draft_id": args.draft_id, "result": result}
297
+ if args.json_output:
298
+ _print_json(payload)
299
+ else:
300
+ print(f"Unscheduled draft {args.draft_id}")
301
+
302
+
303
+ def _drafts_publish(api, args):
304
+ _confirm("Publish", args.draft_id, args.yes, args.json_output)
305
+ prepublish = api.prepublish_draft(args.draft_id)
306
+ result = api.publish_draft(
307
+ args.draft_id,
308
+ send=args.send,
309
+ share_automatically=args.share_automatically,
310
+ )
311
+ payload = {
312
+ "action": "publish",
313
+ "draft_id": args.draft_id,
314
+ "prepublish": prepublish,
315
+ "result": result,
316
+ }
317
+ if args.json_output:
318
+ _print_json(payload)
319
+ else:
320
+ delivery = "without email" if not args.send else "with email"
321
+ print(f"Published draft {args.draft_id} {delivery}")
322
+
323
+
324
+ def _drafts_delete(api, args):
325
+ _confirm("Delete", args.draft_id, args.yes, args.json_output)
326
+ result = api.delete_draft(args.draft_id)
327
+ payload = {"action": "delete", "draft_id": args.draft_id, "result": result}
328
+ if args.json_output:
329
+ _print_json(payload)
330
+ else:
331
+ print(f"Deleted draft {args.draft_id}")
332
+
333
+
334
+ def _build_parser():
335
+ parser = argparse.ArgumentParser(
336
+ prog="substack", description="Manage Substack publications and drafts."
337
+ )
338
+ parser.add_argument("--cookies", help="Path to a cookies JSON file.")
339
+ parser.add_argument(
340
+ "--publication-url", help="Override PUBLICATION_URL for this command."
341
+ )
342
+ parser.add_argument("--json", action="store_true", dest="json_output")
343
+ parser.add_argument("--version", action="version", version=__version__)
344
+
345
+ commands = parser.add_subparsers(dest="command", required=True)
346
+ status = commands.add_parser(
347
+ "status", help="Show authentication and publication status."
348
+ )
349
+ status.set_defaults(handler=_status)
350
+
351
+ publications = commands.add_parser("publications", help="Manage publications.")
352
+ publication_commands = publications.add_subparsers(
353
+ dest="publication_command", required=True
354
+ )
355
+ publications_list = publication_commands.add_parser(
356
+ "list", help="List available publications."
357
+ )
358
+ publications_list.set_defaults(handler=_publications_list)
359
+
360
+ drafts = commands.add_parser("drafts", help="Manage drafts.")
361
+ draft_commands = drafts.add_subparsers(dest="draft_command", required=True)
362
+
363
+ drafts_list = draft_commands.add_parser("list", help="List drafts.")
364
+ drafts_list.add_argument("--filter", default="draft")
365
+ drafts_list.add_argument("--offset", type=int, default=0)
366
+ drafts_list.add_argument("--limit", type=int, default=25)
367
+ drafts_list.set_defaults(handler=_drafts_list)
368
+
369
+ drafts_get = draft_commands.add_parser("get", help="Inspect a draft.")
370
+ drafts_get.add_argument("draft_id", type=int)
371
+ drafts_get.set_defaults(handler=_drafts_get)
372
+
373
+ drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
374
+ drafts_schedule.add_argument("draft_id", type=int)
375
+ drafts_schedule.add_argument("--at", required=True)
376
+ drafts_schedule.set_defaults(handler=_drafts_schedule)
377
+
378
+ drafts_unschedule = draft_commands.add_parser(
379
+ "unschedule", help="Remove a draft schedule."
380
+ )
381
+ drafts_unschedule.add_argument("draft_id", type=int)
382
+ drafts_unschedule.set_defaults(handler=_drafts_unschedule)
383
+
384
+ drafts_publish = draft_commands.add_parser("publish", help="Publish a draft.")
385
+ drafts_publish.add_argument("draft_id", type=int)
386
+ drafts_publish.add_argument(
387
+ "--no-send", action="store_false", dest="send", default=True
388
+ )
389
+ drafts_publish.add_argument("--share-automatically", action="store_true")
390
+ drafts_publish.add_argument("--yes", action="store_true")
391
+ drafts_publish.set_defaults(handler=_drafts_publish)
392
+
393
+ drafts_delete = draft_commands.add_parser("delete", help="Delete a draft.")
394
+ drafts_delete.add_argument("draft_id", type=int)
395
+ drafts_delete.add_argument("--yes", action="store_true")
396
+ drafts_delete.set_defaults(handler=_drafts_delete)
397
+ return parser
398
+
399
+
400
+ def main(argv=None):
401
+ parser = _build_parser()
402
+ args = parser.parse_args(argv)
403
+ try:
404
+ api = _api_from_env(
405
+ cookies_path=args.cookies,
406
+ publication_url=args.publication_url,
407
+ )
408
+ args.handler(api, args)
409
+ except CLIUsageError as exc:
410
+ _print_usage_error(exc, args.json_output)
411
+ return 2
412
+ except (SubstackAPIException, SubstackRequestException, OSError, ValueError) as exc:
413
+ _print_error(exc, args.json_output)
414
+ return 1
415
+ return 0
416
+
417
+
418
+ def publish_markdown(argv=None):
419
+ parser = argparse.ArgumentParser()
420
+ parser.add_argument("markdown", nargs="?", default="README.md")
421
+ parser.add_argument("-m", "--markdown-file", dest="markdown_file")
422
+ parser.add_argument("--title")
423
+ parser.add_argument("--subtitle", default="")
424
+ parser.add_argument("--audience", default="everyone")
425
+ parser.add_argument("--write-comment-permissions", default="everyone")
426
+ parser.add_argument("--search-engine-title")
427
+ parser.add_argument("--search-engine-description")
428
+ parser.add_argument("--slug")
429
+ parser.add_argument("--draft-section-id", type=int)
430
+ parser.add_argument("--tag", action="append", dest="tags")
431
+ parser.add_argument("--cookies")
432
+ parser.add_argument("--prepublish", action="store_true")
433
+ parser.add_argument("--publish", action="store_true")
434
+ parser.add_argument("--no-send", action="store_false", dest="send", default=True)
435
+ parser.add_argument("--share-automatically", action="store_true")
436
+ args = parser.parse_args(argv)
437
+
438
+ markdown_path = Path(args.markdown_file or args.markdown)
439
+ markdown = markdown_path.read_text(encoding="utf-8")
440
+
441
+ api = _api_from_env(args.cookies)
442
+ result = api.create_draft_from_markdown(
443
+ title=args.title or _title_from_markdown(markdown, markdown_path.stem),
444
+ markdown=markdown,
445
+ subtitle=args.subtitle,
446
+ audience=args.audience,
447
+ write_comment_permissions=args.write_comment_permissions,
448
+ search_engine_title=args.search_engine_title,
449
+ search_engine_description=args.search_engine_description,
450
+ slug=args.slug,
451
+ draft_section_id=args.draft_section_id,
452
+ tags=args.tags,
453
+ prepublish=args.prepublish or args.publish,
454
+ publish=args.publish,
455
+ send=args.send,
456
+ share_automatically=args.share_automatically,
457
+ )
458
+ _print_result(result)
459
+ return 0
460
+
461
+
462
+ def publish_yaml(argv=None):
463
+ parser = argparse.ArgumentParser()
464
+ parser.add_argument("post", nargs="?", default="draft.yaml")
465
+ parser.add_argument("-p", "--post-file", dest="post_file")
466
+ parser.add_argument("--cookies")
467
+ parser.add_argument("--prepublish", action="store_true")
468
+ parser.add_argument("--publish", action="store_true")
469
+ parser.add_argument("--no-send", action="store_false", dest="send", default=True)
470
+ parser.add_argument("--share-automatically", action="store_true")
471
+ args = parser.parse_args(argv)
472
+
473
+ post_path = Path(args.post_file or args.post)
474
+ post_data = yaml.safe_load(post_path.read_text(encoding="utf-8"))
475
+ api = _api_from_env(args.cookies)
476
+
477
+ markdown = post_data.get("markdown")
478
+ markdown_file = post_data.get("markdown_file")
479
+ if markdown_file:
480
+ markdown_path = Path(markdown_file)
481
+ if not markdown_path.is_absolute():
482
+ markdown_path = post_path.parent / markdown_path
483
+ markdown = markdown_path.read_text(encoding="utf-8")
484
+
485
+ if markdown is not None:
486
+ result = api.create_draft_from_markdown(
487
+ title=post_data.get("title"),
488
+ markdown=markdown,
489
+ subtitle=post_data.get("subtitle", ""),
490
+ audience=post_data.get("audience", "everyone"),
491
+ write_comment_permissions=post_data.get(
492
+ "write_comment_permissions", "everyone"
493
+ ),
494
+ search_engine_title=post_data.get("search_engine_title"),
495
+ search_engine_description=post_data.get("search_engine_description"),
496
+ slug=post_data.get("slug"),
497
+ draft_section_id=post_data.get("draft_section_id"),
498
+ tags=post_data.get("tags"),
499
+ prepublish=args.prepublish or args.publish,
500
+ publish=args.publish,
501
+ send=args.send,
502
+ share_automatically=args.share_automatically,
503
+ )
504
+ _print_result(result)
505
+ return 0
506
+
507
+ post = Post(
508
+ post_data.get("title"),
509
+ post_data.get("subtitle", ""),
510
+ api.get_user_id(),
511
+ audience=post_data.get("audience", "everyone"),
512
+ write_comment_permissions=post_data.get(
513
+ "write_comment_permissions", "everyone"
514
+ ),
515
+ )
516
+
517
+ section = post_data.get("section")
518
+ if section:
519
+ post.set_section(section, api.get_sections())
520
+
521
+ for item in post_data.get("body", {}).values():
522
+ if item.get("type") == "captionedImage":
523
+ src = item.get("src", "")
524
+ if not src.startswith("http"):
525
+ image = api.get_image(src)
526
+ item.update({"src": image.get("url")})
527
+ post.add(item)
528
+
529
+ draft = api.post_draft(post.get_draft())
530
+ draft_id = draft.get("id")
531
+
532
+ update_payload = {
533
+ "draft_section_id": post.draft_section_id,
534
+ "search_engine_title": post_data.get("search_engine_title"),
535
+ "search_engine_description": post_data.get("search_engine_description"),
536
+ "slug": post_data.get("slug"),
537
+ }
538
+ update_payload = {
539
+ key: value for key, value in update_payload.items() if value is not None
540
+ }
541
+ if update_payload:
542
+ draft = api.put_draft(draft_id, **update_payload)
543
+
544
+ tags = Api._normalize_tags(post_data.get("tags"))
545
+ if tags:
546
+ api.add_tags_to_post(draft_id, tags)
547
+
548
+ if args.prepublish or args.publish:
549
+ api.prepublish_draft(draft_id)
550
+ if args.publish:
551
+ api.publish_draft(
552
+ draft_id,
553
+ send=args.send,
554
+ share_automatically=args.share_automatically,
555
+ )
556
+
557
+ print(json.dumps({"draft_id": draft_id, "draft": draft}, indent=2))
558
+ return 0
559
+
560
+
561
+ def auth_check(argv=None):
562
+ parser = argparse.ArgumentParser()
563
+ parser.add_argument("--cookies")
564
+ args = parser.parse_args(argv)
565
+
566
+ api = _api_from_env(args.cookies)
567
+ profile = api.get_user_profile()
568
+ primary_publication = api.get_user_primary_publication()
569
+ publications = api.get_user_publications()
570
+
571
+ identity = (
572
+ profile.get("email")
573
+ or profile.get("name")
574
+ or profile.get("handle")
575
+ or str(profile.get("id", "unknown"))
576
+ )
577
+
578
+ print(f"Authenticated as: {identity}")
579
+ print(f"Auth method: {_auth_method(args.cookies)}")
580
+ print(f"Primary publication: {primary_publication.get('name', 'unknown')}")
581
+ print(f"Publication URL: {primary_publication.get('publication_url', 'unknown')}")
582
+ print(f"Available publications: {len(publications)}")
583
+ return 0