Skip to content

Commit 4eb7120

Browse files
authored
Merge pull request #1 from hogier:develop
Develop
2 parents 0c1407a + 0e21abd commit 4eb7120

10 files changed

Lines changed: 373 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,5 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
131+
.idea/

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
# python-substack
2-
Substack API python implementation
1+
# Welcome to Python Substack
2+
3+
Updated

examples/publish_post.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import os
2+
3+
from dotenv import load_dotenv
4+
5+
from substack import Api
6+
7+
load_dotenv()
8+
9+
content = ""
10+
title = ""
11+
subtitle = ""
12+
13+
api = Api(
14+
email=os.getenv("EMAIL"),
15+
password=os.getenv("PASSWORD"),
16+
publication_url=os.getenv("PUBLICATION_URL"),
17+
)
18+
19+
body = f'{{"type":"doc","content": {content}}}'
20+
21+
draft = api.post_draft(
22+
[{"id": os.getenv("USER_ID"), "is_guest": False}],
23+
title=title,
24+
subtitle=subtitle,
25+
body=body,
26+
)
27+
28+
api.prepublish_draft(draft.get("id"))
29+
30+
api.publish_draft(draft.get("id"))

pyproject.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[tool.poetry]
2+
name = "python-substack"
3+
version = "0.0.2"
4+
description = "A Python wrapper around the Substack API."
5+
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6+
license = "MIT"
7+
packages = [
8+
{ include = "substack" }
9+
]
10+
11+
readme = "README.md"
12+
13+
repository = "https://github.com/hogier/python-substack"
14+
homepage = "https://github.com/hogier/python-substack"
15+
16+
keywords = ["substack"]
17+
18+
[tool.poetry.dependencies]
19+
python = "^3.8"
20+
python-dotenv = "^0.20.0"
21+
requests = "^2.28.1"
22+
23+
[tool.poetry.dev-dependencies]
24+
25+
26+
[build-system]
27+
requires = ["poetry-core>=1.0.0"]
28+
build-backend = "poetry.core.masonry.api"

substack/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""A library that provides a Python interface to the Substack API."""
2+
3+
__author__ = "Paolo Mazza"
4+
__email__ = "mazzapaolo2019@gmail.com"
5+
__license__ = "MIT License"
6+
__version__ = "1.0"
7+
__url__ = "https://github.com/hogier/python-substack"
8+
__download_url__ = "https://pypi.python.org/pypi/python-substack"
9+
__description__ = "A Python wrapper around the Substack API"
10+
11+
from .api import Api

substack/api.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import logging
2+
3+
import requests
4+
5+
from substack.exceptions import SubstackAPIException, SubstackRequestException
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class Api:
11+
"""
12+
13+
A python interface into the Substack API
14+
15+
"""
16+
17+
def __init__(
18+
self,
19+
email: str,
20+
password: str,
21+
base_url: str | None = None,
22+
publication_url: str | None = None,
23+
debug: bool = False,
24+
):
25+
"""
26+
27+
To create an instance of the substack.Api class:
28+
>>> import substack
29+
>>> api = substack.Api(email="substack email", password="substack password")
30+
31+
Args:
32+
email:
33+
password:
34+
base_url:
35+
The base URL to use to contact the Substack API.
36+
Defaults to https://substack.com/api/v1.
37+
"""
38+
self.base_url = base_url or "https://substack.com/api/v1"
39+
self.publication_url = publication_url
40+
41+
if debug:
42+
logging.basicConfig()
43+
logging.getLogger().setLevel(logging.DEBUG)
44+
45+
self._init_session(email, password)
46+
47+
def login(self, email: str, password: str) -> dict:
48+
"""
49+
50+
Args:
51+
email:
52+
password:
53+
"""
54+
55+
response = self._session.post(
56+
f"{self.base_url}/login",
57+
json={
58+
"captcha_response": None,
59+
"email": email,
60+
"for_pub": "",
61+
"password": password,
62+
"redirect": "/",
63+
},
64+
)
65+
return Api._handle_response(response=response)
66+
67+
def _init_session(self, email, password):
68+
self._session = requests.Session()
69+
70+
self.login(email, password)
71+
72+
@staticmethod
73+
def _handle_response(response: requests.Response):
74+
"""
75+
76+
Internal helper for handling API responses from the Substack server.
77+
Raises the appropriate exceptions when necessary; otherwise, returns the
78+
response.
79+
80+
"""
81+
82+
if not (200 <= response.status_code < 300):
83+
raise SubstackAPIException(response.status_code, response.text)
84+
try:
85+
return response.json()
86+
except ValueError:
87+
raise SubstackRequestException("Invalid Response: %s" % response.text)
88+
89+
def get_publication_users(self):
90+
"""
91+
92+
:return:
93+
"""
94+
response = self._session.get(f"{self.publication_url}/publication/users")
95+
96+
return Api._handle_response(response=response)
97+
98+
def get_posts(self) -> dict:
99+
"""
100+
101+
:return:
102+
"""
103+
response = self._session.get(f"{self.base_url}/reader/posts")
104+
105+
return Api._handle_response(response=response)
106+
107+
def get_drafts(self, filter: str = None, offset: int = None, limit: int = None):
108+
response = self._session.get(
109+
f"{self.publication_url}/drafts",
110+
params={"filter": filter, "offset": offset, "limit": limit},
111+
)
112+
return Api._handle_response(response=response)
113+
114+
def post_draft(
115+
self,
116+
draft_bylines: list,
117+
title: str = None,
118+
subtitle: str = None,
119+
body: str = None,
120+
) -> dict:
121+
"""
122+
123+
Args:
124+
draft_bylines:
125+
title:
126+
subtitle:
127+
body:
128+
129+
Returns:
130+
131+
"""
132+
response = self._session.post(
133+
f"{self.publication_url}/drafts",
134+
json={
135+
"draft_bylines": draft_bylines,
136+
"draft_title": title,
137+
"draft_subtitle": subtitle,
138+
"draft_body": body,
139+
},
140+
)
141+
return Api._handle_response(response=response)
142+
143+
def put_draft(
144+
self,
145+
draft: str,
146+
title: str = None,
147+
subtitle: str = None,
148+
body: str = None,
149+
cover_image: str = None,
150+
) -> dict:
151+
"""
152+
153+
Args:
154+
draft: draft id
155+
title:
156+
subtitle:
157+
body:
158+
cover_image:
159+
160+
Returns:
161+
162+
"""
163+
164+
response = self._session.put(
165+
f"{self.publication_url}/drafts/{draft}",
166+
json={
167+
"draft_title": title,
168+
"draft_subtitle": subtitle,
169+
"draft_body": body,
170+
"cover_image": cover_image,
171+
},
172+
)
173+
return Api._handle_response(response=response)
174+
175+
def prepublish_draft(self, draft: str) -> dict:
176+
"""
177+
178+
Args:
179+
draft: draft id
180+
181+
Returns:
182+
183+
"""
184+
185+
response = self._session.get(
186+
f"{self.publication_url}/drafts/{draft}/prepublish"
187+
)
188+
return Api._handle_response(response=response)
189+
190+
def publish_draft(
191+
self, draft: str, send: bool = True, share_automatically: bool = False
192+
) -> dict:
193+
"""
194+
195+
Args:
196+
draft: draft id
197+
send:
198+
share_automatically:
199+
200+
Returns:
201+
202+
"""
203+
response = self._session.post(
204+
f"{self.publication_url}/drafts/{draft}/publish",
205+
json={"send": send, "share_automatically": share_automatically},
206+
)
207+
return Api._handle_response(response=response)

substack/exceptions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
3+
4+
class SubstackAPIException(Exception):
5+
def __init__(self, status_code, text):
6+
try:
7+
json_res = json.loads(text)
8+
except ValueError:
9+
self.message = f"Invalid JSON error message from Substack: {text}"
10+
else:
11+
self.message = ", ".join(
12+
list(
13+
map(lambda error: error.get("msg", ""), json_res.get("errors", []))
14+
)
15+
)
16+
self.message = self.message or json_res.get("error", "")
17+
self.status_code = status_code
18+
19+
def __str__(self):
20+
return f"APIError(code={self.status_code}): {self.message}"
21+
22+
23+
class SubstackRequestException(Exception):
24+
def __init__(self, message):
25+
self.message = message
26+
27+
def __str__(self):
28+
return f"SubstackRequestException: {self.message}"

tests/__init__.py

Whitespace-only changes.

tests/substack/__init__.py

Whitespace-only changes.

tests/substack/test_api.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import os
2+
import unittest
3+
4+
from dotenv import load_dotenv
5+
6+
from substack import Api
7+
from substack.exceptions import SubstackAPIException
8+
9+
load_dotenv()
10+
11+
12+
class ApiTest(unittest.TestCase):
13+
def test_api_exception(self):
14+
with self.assertRaises(SubstackAPIException):
15+
Api(email="", password="")
16+
17+
def test_login(self):
18+
api = Api(
19+
email=os.getenv("EMAIL"),
20+
password=os.getenv("PASSWORD"),
21+
publication_url=os.getenv("PUBLICATION_URL"),
22+
)
23+
self.assertIsNotNone(api)
24+
25+
def test_get_posts(self):
26+
api = Api(email=os.getenv("EMAIL"), password=os.getenv("PASSWORD"))
27+
posts = api.get_posts()
28+
self.assertIsNotNone(posts)
29+
30+
def test_get_drafts(self):
31+
api = Api(
32+
email=os.getenv("EMAIL"),
33+
password=os.getenv("PASSWORD"),
34+
publication_url=os.getenv("PUBLICATION_URL"),
35+
)
36+
drafts = api.get_drafts()
37+
self.assertIsNotNone(drafts)
38+
39+
def test_post_draft(self):
40+
api = Api(
41+
email=os.getenv("EMAIL"),
42+
password=os.getenv("PASSWORD"),
43+
publication_url=os.getenv("PUBLICATION_URL"),
44+
)
45+
posted_draft = api.post_draft([{"id": os.getenv("USER_ID"), "is_guest": False}])
46+
self.assertIsNotNone(posted_draft)
47+
48+
def test_publication_users(self):
49+
api = Api(
50+
email=os.getenv("EMAIL"),
51+
password=os.getenv("PASSWORD"),
52+
publication_url=os.getenv("PUBLICATION_URL"),
53+
)
54+
users = api.get_publication_users()
55+
self.assertIsNotNone(users)
56+
57+
def test_put_draft(self):
58+
api = Api(
59+
email=os.getenv("EMAIL"),
60+
password=os.getenv("PASSWORD"),
61+
publication_url=os.getenv("PUBLICATION_URL"),
62+
)
63+
posted_draft = api.put_draft("")
64+
self.assertIsNotNone(posted_draft)

0 commit comments

Comments
 (0)