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.
@@ -0,0 +1,433 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-substack
3
+ Version: 0.1.26
4
+ Summary: A Python SDK and CLI for managing Substack publications and drafts.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
+ Author: Paolo Mazza
9
+ Author-email: mazzapaolo2019@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Communications :: Email
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Provides-Extra: mcp
24
+ Requires-Dist: PyYAML (>=6.0,<7.0)
25
+ Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
26
+ Requires-Dist: markdown-it-py (>=3.0,<4.0)
27
+ Requires-Dist: mdit-py-plugins (>=0.4,<0.5)
28
+ Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
29
+ Requires-Dist: requests (>=2.32.0,<3.0.0)
30
+ Project-URL: Changelog, https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md
31
+ Project-URL: Homepage, https://github.com/ma2za/python-substack
32
+ Project-URL: Issues, https://github.com/ma2za/python-substack/issues
33
+ Project-URL: Repository, https://github.com/ma2za/python-substack
34
+ Description-Content-Type: text/markdown
35
+
36
+ # Python Substack
37
+
38
+ An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/python-substack)](https://pypi.org/project/python-substack/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/python-substack)](https://pypi.org/project/python-substack/)
42
+ [![Tests](https://github.com/ma2za/python-substack/actions/workflows/ci.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
43
+ [![Release](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
44
+ [![License](https://img.shields.io/pypi/l/python-substack)](LICENSE)
45
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
46
+
47
+ ## Features
48
+
49
+ - Inspect authentication and publication status from the terminal.
50
+ - List publications and inspect, schedule, publish, or delete drafts.
51
+ - Use stable JSON output in scripts and automation.
52
+ - Create drafts and publish posts from Python.
53
+ - Convert Markdown into Substack's editor document format.
54
+ - Upload local images while rendering Markdown.
55
+ - Set audience, comment permissions, SEO title, SEO description, slug, sections, and tags.
56
+ - Publish now, schedule drafts, or keep drafts unpublished by default.
57
+ - Authenticate with email/password, cookies JSON, or a browser cookie string.
58
+ - Run a FastMCP server for AI-assisted publishing workflows.
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install python-substack
64
+ ```
65
+
66
+ Install the MCP server extra:
67
+
68
+ ```bash
69
+ pip install "python-substack[mcp]"
70
+ ```
71
+
72
+ ## Setup
73
+
74
+ Copy `.env.example` to `.env` and fill in one authentication method:
75
+
76
+ ```env
77
+ EMAIL=
78
+ PASSWORD=
79
+ PUBLICATION_URL=
80
+ COOKIES_PATH=
81
+ COOKIES_STRING=
82
+ ```
83
+
84
+ Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_PATH` or `COOKIES_STRING`. Cookie authentication is usually the better option if Substack prompts for captcha or magic-link sign-in.
85
+
86
+ Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
87
+
88
+ ## CLI Operations
89
+
90
+ Check authentication, the selected publication, and subscriber count:
91
+
92
+ ```bash
93
+ substack status
94
+ ```
95
+
96
+ List available publications or target one without changing `.env`:
97
+
98
+ ```bash
99
+ substack publications list
100
+ substack --publication-url https://example.substack.com drafts list
101
+ ```
102
+
103
+ List and inspect drafts:
104
+
105
+ ```bash
106
+ substack drafts list --limit 10
107
+ substack drafts get 12345
108
+ ```
109
+
110
+ Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
111
+
112
+ ```bash
113
+ substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
114
+ substack drafts unschedule 12345
115
+ ```
116
+
117
+ Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
118
+
119
+ ```bash
120
+ substack drafts publish 12345 --no-send
121
+ substack drafts delete 12345 --yes
122
+ ```
123
+
124
+ Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
125
+
126
+ ```bash
127
+ substack --json drafts list
128
+ substack --cookies cookies.json --json status
129
+ ```
130
+
131
+ ## Quickstart
132
+
133
+ ```python
134
+ import os
135
+
136
+ from dotenv import load_dotenv
137
+ from substack import Api
138
+
139
+ load_dotenv()
140
+
141
+ api = Api(
142
+ email=os.getenv("EMAIL"),
143
+ password=os.getenv("PASSWORD"),
144
+ publication_url=os.getenv("PUBLICATION_URL"),
145
+ )
146
+
147
+ result = api.create_draft_from_markdown(
148
+ title="Shipping with Python",
149
+ subtitle="A short note from a script",
150
+ markdown="""
151
+ # Hello
152
+
153
+ This draft was created from **Markdown**.
154
+
155
+ ![Alt text](https://example.com/image.png "Image caption")
156
+ """,
157
+ tags=["python", "automation"],
158
+ slug="shipping-with-python",
159
+ )
160
+
161
+ print(result["draft"]["id"])
162
+ ```
163
+
164
+ `create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
165
+
166
+ ## Content Publishing CLI
167
+
168
+ Check authentication without creating a draft:
169
+
170
+ ```bash
171
+ substack-auth-check
172
+ ```
173
+
174
+ With a cookies JSON file:
175
+
176
+ ```bash
177
+ substack-auth-check --cookies cookies.json
178
+ ```
179
+
180
+ Publish a Markdown file as a draft:
181
+
182
+ ```bash
183
+ substack-publish-markdown post.md --title "My Post"
184
+ ```
185
+
186
+ Create and publish:
187
+
188
+ ```bash
189
+ substack-publish-markdown post.md --title "My Post" --publish
190
+ ```
191
+
192
+ Publish from YAML:
193
+
194
+ ```bash
195
+ substack-publish-yaml draft.yaml
196
+ ```
197
+
198
+ Useful options:
199
+
200
+ ```bash
201
+ substack-publish-markdown post.md \
202
+ --title "My Post" \
203
+ --subtitle "Optional subtitle" \
204
+ --tag python \
205
+ --tag substack \
206
+ --slug my-post \
207
+ --search-engine-title "SEO title" \
208
+ --search-engine-description "SEO description"
209
+ ```
210
+
211
+ ## Cookie Authentication
212
+
213
+ Cookie authentication avoids logging in with email/password on every run and helps when Substack requires captcha or magic-link sign-in.
214
+
215
+ Use a cookies JSON file:
216
+
217
+ ```python
218
+ import os
219
+
220
+ from dotenv import load_dotenv
221
+ from substack import Api
222
+
223
+ load_dotenv()
224
+
225
+ api = Api(
226
+ cookies_path=os.getenv("COOKIES_PATH"),
227
+ publication_url=os.getenv("PUBLICATION_URL"),
228
+ )
229
+ ```
230
+
231
+ Or paste a browser cookie header into `COOKIES_STRING`:
232
+
233
+ ```python
234
+ import os
235
+
236
+ from dotenv import load_dotenv
237
+ from substack import Api
238
+
239
+ load_dotenv()
240
+
241
+ api = Api(
242
+ cookies_string=os.getenv("COOKIES_STRING"),
243
+ publication_url=os.getenv("PUBLICATION_URL"),
244
+ )
245
+ ```
246
+
247
+ To get a cookie string:
248
+
249
+ 1. Sign in to Substack in your browser.
250
+ 2. Open developer tools.
251
+ 3. Go to the network tab and refresh Substack.
252
+ 4. Select a request such as `subscription/unred/subscriptions`.
253
+ 5. Copy the full `cookie` request header value into `COOKIES_STRING`.
254
+
255
+ To export a working session to a cookies JSON file:
256
+
257
+ ```python
258
+ api.export_cookies("cookies.json")
259
+ ```
260
+
261
+ Then set:
262
+
263
+ ```env
264
+ COOKIES_PATH=cookies.json
265
+ ```
266
+
267
+ The CLI also accepts a cookie JSON path:
268
+
269
+ ```bash
270
+ substack-publish-markdown post.md --cookies cookies.json
271
+ ```
272
+
273
+ ## Low-Level Post Builder
274
+
275
+ ```python
276
+ import os
277
+
278
+ from dotenv import load_dotenv
279
+ from substack import Api
280
+ from substack.post import Post
281
+
282
+ load_dotenv()
283
+
284
+ api = Api(
285
+ email=os.getenv("EMAIL"),
286
+ password=os.getenv("PASSWORD"),
287
+ publication_url=os.getenv("PUBLICATION_URL"),
288
+ )
289
+
290
+ user_id = api.get_user_id()
291
+
292
+ post = Post(
293
+ title="How to publish a Substack post using Python",
294
+ subtitle="Created with python-substack",
295
+ user_id=user_id,
296
+ audience="everyone",
297
+ write_comment_permissions="everyone",
298
+ )
299
+
300
+ post.paragraph("This is a paragraph.")
301
+ post.add(
302
+ {
303
+ "type": "paragraph",
304
+ "content": [
305
+ {"content": "A link to "},
306
+ {
307
+ "content": "Substack",
308
+ "marks": [{"type": "link", "href": "https://substack.com"}],
309
+ },
310
+ ],
311
+ }
312
+ )
313
+ post.add({"type": "paywall"})
314
+ post.add({"type": "captionedImage", "src": "https://example.com/image.png"})
315
+
316
+ draft = api.post_draft(post.get_draft())
317
+ api.prepublish_draft(draft.get("id"))
318
+ api.publish_draft(draft.get("id"))
319
+ ```
320
+
321
+ ## Markdown Support
322
+
323
+ ```python
324
+ from substack.post import Post
325
+
326
+ post = Post("Title", "Subtitle", user_id=1)
327
+ post.from_markdown(
328
+ """
329
+ # Heading
330
+
331
+ Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and footnotes.[^1]
332
+
333
+ - Lists
334
+ - Images
335
+
336
+ ![Alt](local-image.png "Caption")
337
+
338
+ [^1]: Footnote text.
339
+ """
340
+ )
341
+ ```
342
+
343
+ Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, and footnotes.
344
+
345
+ When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
346
+
347
+ ```python
348
+ post.from_markdown(markdown_content, api=api)
349
+ ```
350
+
351
+ ## YAML Drafts
352
+
353
+ ```yaml
354
+ title: "My Post Title"
355
+ subtitle: "My Post Subtitle"
356
+ audience: "everyone"
357
+ write_comment_permissions: "everyone"
358
+ search_engine_title: "SEO title"
359
+ search_engine_description: "SEO description"
360
+ slug: "my-post-title"
361
+ tags:
362
+ - python
363
+ - substack
364
+ markdown: |
365
+ # Introduction
366
+
367
+ This post body is Markdown.
368
+ ```
369
+
370
+ The lower-level node format is also supported:
371
+
372
+ ```yaml
373
+ title: "My Post Title"
374
+ subtitle: "My Post Subtitle"
375
+ body:
376
+ 0:
377
+ type: "heading"
378
+ level: 1
379
+ content: "Introduction"
380
+ 1:
381
+ type: "paragraph"
382
+ content: "This is a paragraph."
383
+ 2:
384
+ type: "captionedImage"
385
+ src: "local_image.jpg"
386
+ ```
387
+
388
+ ## MCP Server
389
+
390
+ Install the MCP extra:
391
+
392
+ ```bash
393
+ pip install "python-substack[mcp]"
394
+ ```
395
+
396
+ Run the server over stdio:
397
+
398
+ ```bash
399
+ substack-mcp
400
+ ```
401
+
402
+ Equivalent Python entry point:
403
+
404
+ ```bash
405
+ python -c "from substack_mcp.mcp_server import main; main()"
406
+ ```
407
+
408
+ Available tools:
409
+
410
+ - `post_draft_from_markdown(...)`
411
+ - `put_draft(draft_id, update_payload)`
412
+ - `add_tags(draft_id, tags)`
413
+ - `prepublish_draft(draft_id)`
414
+ - `publish_draft(draft_id, send=True, share_automatically=False)`
415
+
416
+ ## Development
417
+
418
+ ```bash
419
+ pip install pre-commit
420
+ pre-commit install
421
+ pytest
422
+ ```
423
+
424
+ Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
425
+
426
+ The CLI operations smoke test is separately opt-in. Set `RUN_SUBSTACK_CLI_E2E=1` to create, schedule, unschedule, inspect, and delete a disposable draft. It never publishes the draft.
427
+
428
+ Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
429
+
430
+ ## Disclaimer
431
+
432
+ This project is not affiliated with Substack.
433
+
@@ -0,0 +1,13 @@
1
+ substack/__init__.py,sha256=6wfj_pMsGW4d-y6wyswxGMMQtu0q41tmalHchGkzOyY,416
2
+ substack/api.py,sha256=QX9A_7PanQd_Q_QJwnxXM9auIHCtKd7rHbFTDATcm-U,22640
3
+ substack/cli.py,sha256=F6ba6mgrBzoHXpwNTgUv8JMRmSKgdP7AHpm89keC8EM,19911
4
+ substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
5
+ substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
6
+ substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
7
+ substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
8
+ substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
9
+ python_substack-0.1.26.dist-info/METADATA,sha256=mw0CiIItyXlZt5jODivLqwcHtMFILPpHZCEJKcsysR4,10702
10
+ python_substack-0.1.26.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
11
+ python_substack-0.1.26.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
12
+ python_substack-0.1.26.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
13
+ python_substack-0.1.26.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.4.0
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [console_scripts]
2
+ substack=substack.cli:main
3
+ substack-auth-check=substack.cli:auth_check
4
+ substack-mcp=substack_mcp.mcp_server:main
5
+ substack-publish-markdown=substack.cli:publish_markdown
6
+ substack-publish-yaml=substack.cli:publish_yaml
7
+
substack/__init__.py CHANGED
@@ -3,9 +3,9 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.24"
6
+ __version__ = "0.1.26"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
- __description__ = "A Python wrapper around the Substack API"
9
+ __description__ = "A Python SDK and CLI for managing Substack publications and drafts"
10
10
 
11
11
  from .api import Api
substack/api.py CHANGED
@@ -9,7 +9,7 @@ import json
9
9
  import logging
10
10
  import os
11
11
  from datetime import datetime
12
- from urllib.parse import urljoin, unquote
12
+ from urllib.parse import unquote, urljoin
13
13
 
14
14
  import requests
15
15
 
@@ -111,20 +111,20 @@ class Api:
111
111
  def _parse_cookies_string(cookies_string: str) -> dict:
112
112
  """
113
113
  Parse a semicolon-separated cookie string into a dictionary.
114
-
114
+
115
115
  Args:
116
116
  cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
117
-
117
+
118
118
  Returns:
119
119
  A dictionary of cookie name-value pairs
120
120
  """
121
121
  cookies = {}
122
- for cookie_pair in cookies_string.split(';'):
122
+ for cookie_pair in cookies_string.split(";"):
123
123
  cookie_pair = cookie_pair.strip()
124
124
  if not cookie_pair:
125
125
  continue
126
- if '=' in cookie_pair:
127
- key, value = cookie_pair.split('=', 1)
126
+ if "=" in cookie_pair:
127
+ key, value = cookie_pair.split("=", 1)
128
128
  key = key.strip()
129
129
  value = value.strip()
130
130
  # URL decode the value (e.g., s%3A becomes s:)
@@ -132,6 +132,14 @@ class Api:
132
132
  cookies[key] = value
133
133
  return cookies
134
134
 
135
+ @staticmethod
136
+ def _normalize_tags(tags):
137
+ if tags is None:
138
+ return []
139
+ if isinstance(tags, str):
140
+ return [tags]
141
+ return [str(tag) for tag in tags]
142
+
135
143
  def login(self, email, password) -> dict:
136
144
  """
137
145
 
@@ -224,7 +232,7 @@ class Api:
224
232
  publication:
225
233
  """
226
234
  custom_domain = publication.get("custom_domain", None)
227
- if not custom_domain and not publication.get('custom_domain_optional', None):
235
+ if not custom_domain and not publication.get("custom_domain_optional", None):
228
236
  publication_url = f"https://{publication['subdomain']}.substack.com"
229
237
  else:
230
238
  publication_url = f"https://{custom_domain}"
@@ -238,9 +246,12 @@ class Api:
238
246
 
239
247
  profile = self.get_user_profile()
240
248
  primary_publication = None
241
-
249
+
242
250
  # Try old API format first (backward compatibility)
243
- if "primaryPublication" in profile and profile["primaryPublication"] is not None:
251
+ if (
252
+ "primaryPublication" in profile
253
+ and profile["primaryPublication"] is not None
254
+ ):
244
255
  primary_publication = profile["primaryPublication"]
245
256
  else:
246
257
  # New API format: look for primary publication in publicationUsers
@@ -252,16 +263,16 @@ class Api:
252
263
  primary_publication = pub_user.get("publication")
253
264
  if primary_publication:
254
265
  break
255
-
266
+
256
267
  # If no primary found, use the first publication
257
268
  if primary_publication is None:
258
269
  primary_publication = publication_users[0].get("publication")
259
-
270
+
260
271
  if primary_publication is None:
261
272
  raise SubstackRequestException(
262
273
  "Could not find primary publication in profile"
263
274
  )
264
-
275
+
265
276
  primary_publication["publication_url"] = self.get_publication_url(
266
277
  primary_publication
267
278
  )
@@ -279,12 +290,12 @@ class Api:
279
290
  # of dictionaries of "name", and "subdomain", and "id"
280
291
  user_publications = []
281
292
  publication_users = profile.get("publicationUsers")
282
-
293
+
283
294
  if publication_users is None:
284
295
  # If publicationUsers is None, return empty list or try to construct from other fields
285
296
  # This maintains backward compatibility while handling new API format
286
297
  return user_publications
287
-
298
+
288
299
  for publication in publication_users:
289
300
  pub = publication.get("publication")
290
301
  if pub is not None:
@@ -414,6 +425,73 @@ class Api:
414
425
  response = self._session.post(f"{self.publication_url}/drafts", json=body)
415
426
  return Api._handle_response(response=response)
416
427
 
428
+ def create_draft_from_markdown(
429
+ self,
430
+ title: str,
431
+ markdown: str,
432
+ subtitle: str = "",
433
+ audience: str = "everyone",
434
+ write_comment_permissions: str = "everyone",
435
+ search_engine_title: str = None,
436
+ search_engine_description: str = None,
437
+ slug: str = None,
438
+ draft_section_id: int = None,
439
+ tags=None,
440
+ prepublish: bool = False,
441
+ publish: bool = False,
442
+ send: bool = True,
443
+ share_automatically: bool = False,
444
+ ) -> dict:
445
+ from substack.post import Post
446
+
447
+ post = Post(
448
+ title=title,
449
+ subtitle=subtitle or "",
450
+ user_id=self.get_user_id(),
451
+ audience=audience,
452
+ write_comment_permissions=write_comment_permissions,
453
+ )
454
+ post.from_markdown(markdown, api=self)
455
+
456
+ draft = self.post_draft(post.get_draft())
457
+ draft_id = draft.get("id")
458
+
459
+ update_payload = {
460
+ "search_engine_title": search_engine_title,
461
+ "search_engine_description": search_engine_description,
462
+ "slug": slug,
463
+ "draft_section_id": draft_section_id,
464
+ }
465
+ update_payload = {
466
+ key: value for key, value in update_payload.items() if value is not None
467
+ }
468
+ if update_payload:
469
+ draft = self.put_draft(draft_id, **update_payload)
470
+
471
+ tags_result = None
472
+ tags_list = self._normalize_tags(tags)
473
+ if tags_list:
474
+ tags_result = self.add_tags_to_post(draft_id, tags_list)
475
+
476
+ prepublish_result = None
477
+ if prepublish:
478
+ prepublish_result = self.prepublish_draft(draft_id)
479
+
480
+ publish_result = None
481
+ if publish:
482
+ publish_result = self.publish_draft(
483
+ draft_id,
484
+ send=send,
485
+ share_automatically=share_automatically,
486
+ )
487
+
488
+ return {
489
+ "draft": draft,
490
+ "tags": tags_result,
491
+ "prepublish": prepublish_result,
492
+ "publish": publish_result,
493
+ }
494
+
417
495
  def put_draft(self, draft, **kwargs) -> dict:
418
496
  """
419
497
 
@@ -514,7 +592,7 @@ class Api:
514
592
  data={"image": image},
515
593
  )
516
594
  return Api._handle_response(response=response)
517
-
595
+
518
596
  def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
519
597
  """
520
598
  Add multiple tags to a post.
@@ -575,7 +653,6 @@ class Api:
575
653
  )
576
654
  return Api._handle_response(apply_tag_response)
577
655
 
578
-
579
656
  def get_categories(self):
580
657
  """
581
658