From 0ff6e9a5d5133ca8354780ccbc01da738dc29baa Mon Sep 17 00:00:00 2001 From: mazza Date: Mon, 27 Jun 2022 10:37:44 +0200 Subject: [PATCH 1/8] first commit --- .idea/.gitignore | 3 +++ .idea/sonarlint/issuestore/index.pb | 0 README.md | 5 +++-- requirements.txt | 0 setup.py | 0 substack/__init__.py | 0 substack/client.py | 0 7 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/sonarlint/issuestore/index.pb create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 substack/__init__.py create mode 100644 substack/client.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/sonarlint/issuestore/index.pb b/.idea/sonarlint/issuestore/index.pb new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index af4f9ba..27de585 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# python-substack -Substack API python implementation +# Welcome to python-substack v1.0.0 + +Updated \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69de29 diff --git a/substack/__init__.py b/substack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/substack/client.py b/substack/client.py new file mode 100644 index 0000000..e69de29 From 577e5656741276339b032200370bc2b52c9d475d Mon Sep 17 00:00:00 2001 From: mazza Date: Mon, 27 Jun 2022 12:29:18 +0200 Subject: [PATCH 2/8] login and session --- requirements.txt | 1 + substack/__init__.py | 11 ++++ substack/api.py | 74 +++++++++++++++++++++++++ substack/exceptions.py | 25 +++++++++ substack/client.py => tests/__init__.py | 0 tests/substack/__init__.py | 0 tests/substack/test_api.py | 11 ++++ 7 files changed, 122 insertions(+) create mode 100644 substack/api.py create mode 100644 substack/exceptions.py rename substack/client.py => tests/__init__.py (100%) create mode 100644 tests/substack/__init__.py create mode 100644 tests/substack/test_api.py diff --git a/requirements.txt b/requirements.txt index e69de29..663bd1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +requests \ No newline at end of file diff --git a/substack/__init__.py b/substack/__init__.py index e69de29..6306529 100644 --- a/substack/__init__.py +++ b/substack/__init__.py @@ -0,0 +1,11 @@ +"""A library that provides a Python interface to the Substack API.""" + +__author__ = 'Paolo Mazza' +__email__ = 'mazzapaolo2019@gmail.com' +__license__ = 'MIT License' +__version__ = '1.0' +__url__ = 'https://github.com/hogier/python-substack' +__download_url__ = 'https://pypi.python.org/pypi/python-substack' +__description__ = 'A Python wrapper around the Substack API' + +from .api import Api diff --git a/substack/api.py b/substack/api.py new file mode 100644 index 0000000..7f8c522 --- /dev/null +++ b/substack/api.py @@ -0,0 +1,74 @@ +import logging + +import requests + +from substack.exceptions import SubstackAPIException, SubstackRequestException + +logger = logging.getLogger(__name__) + + +class Api: + """ + + A python interface into the Substack API + + """ + + def __init__(self, email: str, password: str, base_url: str | None = None, debug: bool = False): + """ + + To create an instance of the substack.Api class: + >>> import substack + >>> api = substack.Api(email="substack email", password="substack password") + + Args: + email: + password: + base_url: + The base URL to use to contact the Substack API. + Defaults to https://substack.com/api/v1. + """ + self.base_url = base_url or "https://substack.com/api/v1" + + if debug: + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + + self._init_session(email, password) + + def login(self, email: str, password: str): + """ + + Args: + email: + password: + """ + + response = self._session.post(f"{self.base_url}/login", json={"captcha_response": None, + "email": email, + "for_pub": "", + "password": password, + "redirect": "/"}) + return Api._handle_response(response=response) + + def _init_session(self, email, password): + self._session = requests.Session() + + self.login(email, password) + + @staticmethod + def _handle_response(response: requests.Response): + """ + + Internal helper for handling API responses from the Substack server. + Raises the appropriate exceptions when necessary; otherwise, returns the + response. + + """ + + if not (200 <= response.status_code < 300): + raise SubstackAPIException(response.status_code, response.text) + try: + return response.json() + except ValueError: + raise SubstackRequestException('Invalid Response: %s' % response.text) diff --git a/substack/exceptions.py b/substack/exceptions.py new file mode 100644 index 0000000..017cb03 --- /dev/null +++ b/substack/exceptions.py @@ -0,0 +1,25 @@ +import json + + +class SubstackAPIException(Exception): + + def __init__(self, status_code, text): + try: + json_res = json.loads(text) + except ValueError: + self.message = f'Invalid JSON error message from Substack: {text}' + else: + self.message = ", ".join(list(map(lambda error: error.get("msg", ""), json_res.get("errors", [])))) + self.message = self.message or json_res.get("error", "") + self.status_code = status_code + + def __str__(self): + return f'APIError(code={self.status_code}): {self.message}' + + +class SubstackRequestException(Exception): + def __init__(self, message): + self.message = message + + def __str__(self): + return f'SubstackRequestException: {self.message}' diff --git a/substack/client.py b/tests/__init__.py similarity index 100% rename from substack/client.py rename to tests/__init__.py diff --git a/tests/substack/__init__.py b/tests/substack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/substack/test_api.py b/tests/substack/test_api.py new file mode 100644 index 0000000..48ce351 --- /dev/null +++ b/tests/substack/test_api.py @@ -0,0 +1,11 @@ +import unittest + +from substack import Api +from substack.exceptions import SubstackAPIException + + +class ApiTest(unittest.TestCase): + + def test_api_exception(self): + with self.assertRaises(SubstackAPIException): + Api(email="", password="") From bedf9287fb05ea83e712725cb8e3d0936cc35a79 Mon Sep 17 00:00:00 2001 From: mazza Date: Tue, 5 Jul 2022 09:40:58 +0200 Subject: [PATCH 3/8] remove .idea --- .idea/.gitignore | 3 --- .idea/sonarlint/issuestore/index.pb | 0 2 files changed, 3 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/sonarlint/issuestore/index.pb diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/sonarlint/issuestore/index.pb b/.idea/sonarlint/issuestore/index.pb deleted file mode 100644 index e69de29..0000000 From 246b335b53de7a5894f9dee76a2a4af1f408dafb Mon Sep 17 00:00:00 2001 From: mazza Date: Tue, 5 Jul 2022 17:03:40 +0200 Subject: [PATCH 4/8] first features --- examples/publish_post.py | 30 ++++++++ substack/__init__.py | 14 ++-- substack/api.py | 149 +++++++++++++++++++++++++++++++++++-- substack/exceptions.py | 13 ++-- tests/substack/test_api.py | 55 +++++++++++++- 5 files changed, 240 insertions(+), 21 deletions(-) create mode 100644 examples/publish_post.py diff --git a/examples/publish_post.py b/examples/publish_post.py new file mode 100644 index 0000000..3afe97d --- /dev/null +++ b/examples/publish_post.py @@ -0,0 +1,30 @@ +import os + +from dotenv import load_dotenv + +from substack import Api + +load_dotenv() + +content = "" +title = "" +subtitle = "" + +api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), +) + +body = f'{{"type":"doc","content": {content}}}' + +draft = api.post_draft( + [{"id": os.getenv("USER_ID"), "is_guest": False}], + title=title, + subtitle=subtitle, + body=body, +) + +api.prepublish_draft(draft.get("id")) + +api.publish_draft(draft.get("id")) diff --git a/substack/__init__.py b/substack/__init__.py index 6306529..00538c5 100644 --- a/substack/__init__.py +++ b/substack/__init__.py @@ -1,11 +1,11 @@ """A library that provides a Python interface to the Substack API.""" -__author__ = 'Paolo Mazza' -__email__ = 'mazzapaolo2019@gmail.com' -__license__ = 'MIT License' -__version__ = '1.0' -__url__ = 'https://github.com/hogier/python-substack' -__download_url__ = 'https://pypi.python.org/pypi/python-substack' -__description__ = 'A Python wrapper around the Substack API' +__author__ = "Paolo Mazza" +__email__ = "mazzapaolo2019@gmail.com" +__license__ = "MIT License" +__version__ = "1.0" +__url__ = "https://github.com/hogier/python-substack" +__download_url__ = "https://pypi.python.org/pypi/python-substack" +__description__ = "A Python wrapper around the Substack API" from .api import Api diff --git a/substack/api.py b/substack/api.py index 7f8c522..df5a869 100644 --- a/substack/api.py +++ b/substack/api.py @@ -14,7 +14,14 @@ class Api: """ - def __init__(self, email: str, password: str, base_url: str | None = None, debug: bool = False): + def __init__( + self, + email: str, + password: str, + base_url: str | None = None, + publication_url: str | None = None, + debug: bool = False, + ): """ To create an instance of the substack.Api class: @@ -29,6 +36,7 @@ def __init__(self, email: str, password: str, base_url: str | None = None, debug Defaults to https://substack.com/api/v1. """ self.base_url = base_url or "https://substack.com/api/v1" + self.publication_url = publication_url if debug: logging.basicConfig() @@ -36,7 +44,7 @@ def __init__(self, email: str, password: str, base_url: str | None = None, debug self._init_session(email, password) - def login(self, email: str, password: str): + def login(self, email: str, password: str) -> dict: """ Args: @@ -44,11 +52,16 @@ def login(self, email: str, password: str): password: """ - response = self._session.post(f"{self.base_url}/login", json={"captcha_response": None, - "email": email, - "for_pub": "", - "password": password, - "redirect": "/"}) + response = self._session.post( + f"{self.base_url}/login", + json={ + "captcha_response": None, + "email": email, + "for_pub": "", + "password": password, + "redirect": "/", + }, + ) return Api._handle_response(response=response) def _init_session(self, email, password): @@ -71,4 +84,124 @@ def _handle_response(response: requests.Response): try: return response.json() except ValueError: - raise SubstackRequestException('Invalid Response: %s' % response.text) + raise SubstackRequestException("Invalid Response: %s" % response.text) + + def get_publication_users(self): + """ + + :return: + """ + response = self._session.get(f"{self.publication_url}/publication/users") + + return Api._handle_response(response=response) + + def get_posts(self) -> dict: + """ + + :return: + """ + response = self._session.get(f"{self.base_url}/reader/posts") + + return Api._handle_response(response=response) + + def get_drafts(self, filter: str = None, offset: int = None, limit: int = None): + response = self._session.get( + f"{self.publication_url}/drafts", + params={"filter": filter, "offset": offset, "limit": limit}, + ) + return Api._handle_response(response=response) + + def post_draft( + self, + draft_bylines: list, + title: str = None, + subtitle: str = None, + body: str = None, + ) -> dict: + """ + + Args: + draft_bylines: + title: + subtitle: + body: + + Returns: + + """ + response = self._session.post( + f"{self.publication_url}/drafts", + json={ + "draft_bylines": draft_bylines, + "draft_title": title, + "draft_subtitle": subtitle, + "draft_body": body, + }, + ) + return Api._handle_response(response=response) + + def put_draft( + self, + draft: str, + title: str = None, + subtitle: str = None, + body: str = None, + cover_image: str = None, + ) -> dict: + """ + + Args: + draft: + title: + subtitle: + body: + cover_image: + + Returns: + + """ + + response = self._session.put( + f"{self.publication_url}/drafts/{draft}", + json={ + "draft_title": title, + "draft_subtitle": subtitle, + "draft_body": body, + "cover_image": cover_image, + }, + ) + return Api._handle_response(response=response) + + def prepublish_draft(self, draft: str) -> dict: + """ + + Args: + draft: + + Returns: + + """ + + response = self._session.get( + f"{self.publication_url}/drafts/{draft}/prepublish" + ) + return Api._handle_response(response=response) + + def publish_draft( + self, draft: str, send: bool = True, share_automatically: bool = False + ) -> dict: + """ + + Args: + draft: + send: + share_automatically: + + Returns: + + """ + response = requests.post( + f"{self.publication_url}/drafts/{draft}/publish", + json={"send": send, "share_automatically": share_automatically}, + ) + return Api._handle_response(response=response) diff --git a/substack/exceptions.py b/substack/exceptions.py index 017cb03..27a0301 100644 --- a/substack/exceptions.py +++ b/substack/exceptions.py @@ -2,19 +2,22 @@ class SubstackAPIException(Exception): - def __init__(self, status_code, text): try: json_res = json.loads(text) except ValueError: - self.message = f'Invalid JSON error message from Substack: {text}' + self.message = f"Invalid JSON error message from Substack: {text}" else: - self.message = ", ".join(list(map(lambda error: error.get("msg", ""), json_res.get("errors", [])))) + self.message = ", ".join( + list( + map(lambda error: error.get("msg", ""), json_res.get("errors", [])) + ) + ) self.message = self.message or json_res.get("error", "") self.status_code = status_code def __str__(self): - return f'APIError(code={self.status_code}): {self.message}' + return f"APIError(code={self.status_code}): {self.message}" class SubstackRequestException(Exception): @@ -22,4 +25,4 @@ def __init__(self, message): self.message = message def __str__(self): - return f'SubstackRequestException: {self.message}' + return f"SubstackRequestException: {self.message}" diff --git a/tests/substack/test_api.py b/tests/substack/test_api.py index 48ce351..3f211ec 100644 --- a/tests/substack/test_api.py +++ b/tests/substack/test_api.py @@ -1,11 +1,64 @@ +import os import unittest +from dotenv import load_dotenv + from substack import Api from substack.exceptions import SubstackAPIException +load_dotenv() -class ApiTest(unittest.TestCase): +class ApiTest(unittest.TestCase): def test_api_exception(self): with self.assertRaises(SubstackAPIException): Api(email="", password="") + + def test_login(self): + api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), + ) + self.assertIsNotNone(api) + + def test_get_posts(self): + api = Api(email=os.getenv("EMAIL"), password=os.getenv("PASSWORD")) + posts = api.get_posts() + self.assertIsNotNone(posts) + + def test_get_drafts(self): + api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), + ) + drafts = api.get_drafts() + self.assertIsNotNone(drafts) + + def test_post_draft(self): + api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), + ) + posted_draft = api.post_draft([{"id": os.getenv("USER_ID"), "is_guest": False}]) + self.assertIsNotNone(posted_draft) + + def test_publication_users(self): + api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), + ) + users = api.get_publication_users() + self.assertIsNotNone(users) + + def test_put_draft(self): + api = Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), + ) + posted_draft = api.put_draft("62667935") + self.assertIsNotNone(posted_draft) From 503e4a49ad8460cd6af6139fc3369474cff04f8d Mon Sep 17 00:00:00 2001 From: mazza Date: Tue, 5 Jul 2022 17:03:50 +0200 Subject: [PATCH 5/8] update requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 663bd1f..323ef8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +python-dotenv requests \ No newline at end of file From a1ae099975b9c5a74077365c62d63462089a71f6 Mon Sep 17 00:00:00 2001 From: mazza Date: Tue, 5 Jul 2022 17:04:20 +0200 Subject: [PATCH 6/8] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b6e4761..1d4e738 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +.idea/ \ No newline at end of file From 191b713c5de8468cffa782f3a6ddce7d93e2aa94 Mon Sep 17 00:00:00 2001 From: mazza Date: Tue, 5 Jul 2022 17:59:43 +0200 Subject: [PATCH 7/8] move to poetry --- pyproject.toml | 28 ++++++++++++++++++++++++++++ requirements.txt | 2 -- setup.py | 0 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..138628d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "python-substack" +version = "0.0.1" +description = "A Python wrapper around the Substack API." +authors = ["Paolo Mazza "] +license = "MIT" +packages = [ + { include = "substack" } +] + +readme = "README.md" + +repository = "https://github.com/hogier/python-substack" +homepage = "https://github.com/hogier/python-substack" + +keywords = ["substack"] + +[tool.poetry.dependencies] +python = "^3.8" +python-dotenv = "^0.20.0" +requests = "^2.28.1" + +[tool.poetry.dev-dependencies] + + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 323ef8c..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -python-dotenv -requests \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index e69de29..0000000 From 0e21abd9f7a17226052f3634210302f25189f380 Mon Sep 17 00:00:00 2001 From: mazza Date: Thu, 7 Jul 2022 15:27:22 +0200 Subject: [PATCH 8/8] minor fixes --- README.md | 2 +- pyproject.toml | 2 +- substack/api.py | 8 ++++---- tests/substack/test_api.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 27de585..8bde094 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# Welcome to python-substack v1.0.0 +# Welcome to Python Substack Updated \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 138628d..a198906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.0.1" +version = "0.0.2" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT" diff --git a/substack/api.py b/substack/api.py index df5a869..9afb039 100644 --- a/substack/api.py +++ b/substack/api.py @@ -151,7 +151,7 @@ def put_draft( """ Args: - draft: + draft: draft id title: subtitle: body: @@ -176,7 +176,7 @@ def prepublish_draft(self, draft: str) -> dict: """ Args: - draft: + draft: draft id Returns: @@ -193,14 +193,14 @@ def publish_draft( """ Args: - draft: + draft: draft id send: share_automatically: Returns: """ - response = requests.post( + response = self._session.post( f"{self.publication_url}/drafts/{draft}/publish", json={"send": send, "share_automatically": share_automatically}, ) diff --git a/tests/substack/test_api.py b/tests/substack/test_api.py index 3f211ec..1b70112 100644 --- a/tests/substack/test_api.py +++ b/tests/substack/test_api.py @@ -60,5 +60,5 @@ def test_put_draft(self): password=os.getenv("PASSWORD"), publication_url=os.getenv("PUBLICATION_URL"), ) - posted_draft = api.put_draft("62667935") + posted_draft = api.put_draft("") self.assertIsNotNone(posted_draft)