From e0743f976efe945e1696de39246bbba28a3d4e9d Mon Sep 17 00:00:00 2001 From: Peter Wicks Date: Thu, 7 Dec 2023 01:52:44 -0700 Subject: [PATCH 1/8] Allow saving / loading of auth cookies. (#25) --- substack/api.py | 68 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/substack/api.py b/substack/api.py index 048618d..83e54fd 100644 --- a/substack/api.py +++ b/substack/api.py @@ -3,6 +3,7 @@ import os from datetime import datetime from urllib.parse import urljoin +import json import requests @@ -24,6 +25,7 @@ def __init__( self, email=None, password=None, + cookies_path=None, base_url=None, publication_url=None, debug=False, @@ -37,6 +39,9 @@ def __init__( Args: email: password: + cookies_path + To re-use your session without logging in each time, you can save your cookies to a json file and then load them in the next session. + Make sure to re-save your cookies, as they do update over time. base_url: The base URL to use to contact the Substack API. Defaults to https://substack.com/api/v1. @@ -49,30 +54,39 @@ def __init__( self._session = requests.Session() - if email is not None and password is not None: + # Load cookies from file if provided + # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud + if cookies_path is not None: + with open(cookies_path, "r") as f: + cookies = json.load(f) + self._session.cookies.update(cookies) + + elif email is not None and password is not None: self.login(email, password) + else: + raise ValueError("Must provide email and password or cookies_path to authenticate.") + + # if the user provided a publication url, then use that + if publication_url: + import re + + # Regular expression to extract subdomain name + match = re.search(r"https://(.*).substack.com", publication_url.lower()) + subdomain = match.group(1) if match else None + + user_publications = self.get_user_publications() + # search through publications to find the publication with the matching subdomain + for publication in user_publications: + if publication['subdomain'] == subdomain: + # set the current publication to the users publication + user_publication = publication + break + else: + # get the users primary publication + user_publication = self.get_user_primary_publication() - # if the user provided a publication url, then use that - if publication_url: - import re - - # Regular expression to extract subdomain name - match = re.search(r"https://(.*).substack.com", publication_url.lower()) - subdomain = match.group(1) if match else None - - user_publications = self.get_user_publications() - # search through publications to find the publication with the matching subdomain - for publication in user_publications: - if publication['subdomain'] == subdomain: - # set the current publication to the users publication - user_publication = publication - break - else: - # get the users primary publication - user_publication = self.get_user_primary_publication() - - # set the current publication to the users primary publication - self.change_publication(user_publication) + # set the current publication to the users primary publication + self.change_publication(user_publication) def login(self, email, password) -> dict: """ @@ -114,6 +128,16 @@ def change_publication(self, publication): # sign-in to the publication self.signin_for_pub(publication) + def export_cookies(self, path: str = "cookies.json"): + """ + Export cookies to a json file. + Args: + path: path to the json file + """ + cookies = self._session.cookies.get_dict() + with open(path, "w") as f: + json.dump(cookies, f) + @staticmethod def _handle_response(response: requests.Response): """ From 5708d5d034e994677626c4949e795c95c3736ce7 Mon Sep 17 00:00:00 2001 From: ma2za Date: Thu, 7 Dec 2023 10:15:02 +0100 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=8F=B0=20upkeep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .pre-commit-config.yaml | 16 ++++++ README.md | 24 +++++++-- examples/draft.yaml | 2 +- poetry.lock | 14 +++--- pyproject.toml | 4 +- substack/api.py | 108 ++++++++++++++++++++++++++-------------- substack/post.py | 60 ++++++++++++++-------- 8 files changed, 155 insertions(+), 75 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.gitignore b/.gitignore index 1d4e738..b97131e 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,4 @@ dmypy.json # Pyre type checker .pyre/ -.idea/ \ No newline at end of file +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..93999f6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + name: isort (python) diff --git a/README.md b/README.md index 7fe7710..9c6a461 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,15 @@ Set the following environment variables by creating a **.env** file: PASSWORD= ## If you don't have a password -Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses your email address with a "magic" link. + +Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses +your email address with a "magic" link. Set a password: - - Sign-out of Substack - - At the sign-in page click, "Sign in with password" under the `Email` text box - - Then choose, "Set a new password" + +- Sign-out of Substack +- At the sign-in page click, "Sign in with password" under the `Email` text box +- Then choose, "Set a new password" The .env file will be ignored by git but always be careful. @@ -100,3 +103,16 @@ api.prepublish_draft(draft.get("id")) api.publish_draft(draft.get("id")) ``` +# Contributing + +Install pre-commit: + +```shell +pip install pre-commit +``` + +Set up pre-commit + +```shell +pre-commit install +``` diff --git a/examples/draft.yaml b/examples/draft.yaml index 97f772c..cb0c54b 100644 --- a/examples/draft.yaml +++ b/examples/draft.yaml @@ -49,4 +49,4 @@ body: src: "EnDg65ISswg" 9: type: "subscribeWidget" - message: "Hello Everyone!!!" \ No newline at end of file + message: "Hello Everyone!!!" diff --git a/poetry.lock b/poetry.lock index 310af14..e417fbc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "certifi" -version = "2023.7.22" +version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] @@ -112,13 +112,13 @@ files = [ [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index cd8d52c..fa48afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.1.13" +version = "0.1.14" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT" @@ -28,4 +28,4 @@ PyYAML = "^6.0" [build-system] requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" \ No newline at end of file +build-backend = "poetry.core.masonry.api" diff --git a/substack/api.py b/substack/api.py index 83e54fd..faf3939 100644 --- a/substack/api.py +++ b/substack/api.py @@ -1,9 +1,15 @@ +""" + +API Wrapper + +""" + import base64 +import json import logging import os from datetime import datetime from urllib.parse import urljoin -import json import requests @@ -22,13 +28,13 @@ class Api: """ def __init__( - self, - email=None, - password=None, - cookies_path=None, - base_url=None, - publication_url=None, - debug=False, + self, + email=None, + password=None, + cookies_path=None, + base_url=None, + publication_url=None, + debug=False, ): """ @@ -40,7 +46,8 @@ def __init__( email: password: cookies_path - To re-use your session without logging in each time, you can save your cookies to a json file and then load them in the next session. + To re-use your session without logging in each time, you can save your cookies to a json file and + then load them in the next session. Make sure to re-save your cookies, as they do update over time. base_url: The base URL to use to contact the Substack API. @@ -57,15 +64,18 @@ def __init__( # Load cookies from file if provided # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud if cookies_path is not None: - with open(cookies_path, "r") as f: + with open(cookies_path) as f: cookies = json.load(f) self._session.cookies.update(cookies) elif email is not None and password is not None: self.login(email, password) else: - raise ValueError("Must provide email and password or cookies_path to authenticate.") + raise ValueError( + "Must provide email and password or cookies_path to authenticate." + ) + user_publication = None # if the user provided a publication url, then use that if publication_url: import re @@ -73,11 +83,11 @@ def __init__( # Regular expression to extract subdomain name match = re.search(r"https://(.*).substack.com", publication_url.lower()) subdomain = match.group(1) if match else None - + user_publications = self.get_user_publications() # search through publications to find the publication with the matching subdomain for publication in user_publications: - if publication['subdomain'] == subdomain: + if publication["subdomain"] == subdomain: # set the current publication to the users publication user_publication = publication break @@ -110,7 +120,7 @@ def login(self, email, password) -> dict: ) return Api._handle_response(response=response) - + def signin_for_pub(self, publication): """ Complete the signin process @@ -118,12 +128,13 @@ def signin_for_pub(self, publication): response = self._session.get( f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}", ) + return Api._handle_response(response=response) def change_publication(self, publication): """ Change the publication URL """ - self.publication_url = urljoin(publication['publication_url'], "api/v1") + self.publication_url = urljoin(publication["publication_url"], "api/v1") # sign-in to the publication self.signin_for_pub(publication) @@ -156,16 +167,25 @@ def _handle_response(response: requests.Response): raise SubstackRequestException("Invalid Response: %s" % response.text) def get_user_id(self): + """ + + Returns: + + """ profile = self.get_user_profile() - user_id = profile['id'] + user_id = profile["id"] return user_id - - def get_publication_url(self, publication): + + @staticmethod + def get_publication_url(publication: dict) -> str: """ Gets the publication url + + Args: + publication: """ - custom_domain = publication['custom_domain'] + custom_domain = publication["custom_domain"] if not custom_domain: publication_url = f"https://{publication['subdomain']}.substack.com" else: @@ -179,8 +199,10 @@ def get_user_primary_publication(self): """ profile = self.get_user_profile() - primary_publication = profile['primaryPublication'] - primary_publication['publication_url'] = self.get_publication_url(primary_publication) + primary_publication = profile["primaryPublication"] + primary_publication["publication_url"] = self.get_publication_url( + primary_publication + ) return primary_publication @@ -191,11 +213,12 @@ def get_user_publications(self): profile = self.get_user_profile() - # Loop through users "publicationUsers" list, and return a list of dictionaries of "name", and "subdomain", and "id" + # Loop through users "publicationUsers" list, and return a list + # of dictionaries of "name", and "subdomain", and "id" user_publications = [] - for publication in profile['publicationUsers']: - pub = publication['publication'] - pub['publication_url'] = self.get_publication_url(pub) + for publication in profile["publicationUsers"]: + pub = publication["publication"] + pub["publication_url"] = self.get_publication_url(pub) user_publications.append(pub) return user_publications @@ -218,7 +241,7 @@ def get_user_settings(self): response = self._session.get(f"{self.base_url}/settings") return Api._handle_response(response=response) - + def get_publication_users(self): """ Get list of users. @@ -238,17 +261,26 @@ def get_publication_subscriber_count(self): Returns: """ - response = self._session.get(f"{self.publication_url}/publication_launch_checklist") + response = self._session.get( + f"{self.publication_url}/publication_launch_checklist" + ) - return Api._handle_response(response=response)['subscriberCount'] + return Api._handle_response(response=response)["subscriberCount"] - def get_published_posts(self, offset=0, limit=25, order_by="post_date", order_direction="desc"): + def get_published_posts( + self, offset=0, limit=25, order_by="post_date", order_direction="desc" + ): """ Get list of published posts for the publication. """ response = self._session.get( f"{self.publication_url}/post_management/published", - params={"offset": offset, "limit": limit, "order_by": order_by, "order_direction": order_direction}, + params={ + "offset": offset, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + }, ) return Api._handle_response(response=response) @@ -312,11 +344,7 @@ def post_draft(self, body) -> dict: response = self._session.post(f"{self.publication_url}/drafts", json=body) return Api._handle_response(response=response) - def put_draft( - self, - draft, - **kwargs - ) -> dict: + def put_draft(self, draft, **kwargs) -> dict: """ Args: @@ -348,7 +376,7 @@ def prepublish_draft(self, draft) -> dict: return Api._handle_response(response=response) def publish_draft( - self, draft, send: bool = True, share_automatically: bool = False + self, draft, send: bool = True, share_automatically: bool = False ) -> dict: """ @@ -466,7 +494,7 @@ def get_single_category(self, category_id, category_type, page=None, limit=None) page_output = self.get_category(category_id, category_type, page) publications.extend(page_output.get("publications", [])) if ( - limit is not None and limit <= len(publications) + limit is not None and limit <= len(publications) ) or not page_output.get("more", False): publications = publications[:limit] break @@ -504,5 +532,9 @@ def get_sections(self): f"{self.publication_url}/subscriptions", ) content = Api._handle_response(response=response) - sections = [p.get("sections") for p in content.get("publications") if p.get("hostname") in self.publication_url] + sections = [ + p.get("sections") + for p in content.get("publications") + if p.get("hostname") in self.publication_url + ] return sections[0] diff --git a/substack/post.py b/substack/post.py index facedcd..bdae760 100644 --- a/substack/post.py +++ b/substack/post.py @@ -1,3 +1,9 @@ +""" + +Post Utilities + +""" + import json from typing import Dict @@ -7,13 +13,19 @@ class Post: + """ + + Post utility class + + """ + def __init__( - self, - title: str, - subtitle: str, - user_id, - audience: str = None, - write_comment_permissions: str = None, + self, + title: str, + subtitle: str, + user_id, + audience: str = None, + write_comment_permissions: str = None, ): """ @@ -143,20 +155,20 @@ def attrs(self, level): return self def captioned_image( - self, - src: str, - fullscreen: bool = False, - imageSize: str = "normal", - height: int = 819, - width: int = 1456, - resizeWidth: int = 728, - bytes: str = None, - alt: str = None, - title: str = None, - type: str = None, - href: str = None, - belowTheFold: bool = False, - internalRedirect: str = None, + self, + src: str, + fullscreen: bool = False, + imageSize: str = "normal", + height: int = 819, + width: int = 1456, + resizeWidth: int = 728, + bytes: str = None, + alt: str = None, + title: str = None, + type: str = None, + href: str = None, + belowTheFold: bool = False, + internalRedirect: str = None, ): """ @@ -252,7 +264,7 @@ def marks(self, marks): return self def remove_last_paragraph(self): - """ """ + """Remove last paragraph""" del self.draft_body.get("content")[-1] def get_draft(self): @@ -282,7 +294,11 @@ def subscribe_with_caption(self, message: str = None): Subscribe for free to receive new posts and support my work.""" subscribe = self.draft_body["content"][-1] - subscribe["attrs"] = {"url": "%%checkout_url%%", "text": "Subscribe", "language": "en"} + subscribe["attrs"] = { + "url": "%%checkout_url%%", + "text": "Subscribe", + "language": "en", + } subscribe["content"] = [ { "type": "ctaCaption", From d79f1ca067bc3fea1ee70e742e034ffb8d3daf37 Mon Sep 17 00:00:00 2001 From: ma2za Date: Tue, 26 Dec 2023 18:08:48 +0100 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=8F=B0=20upkeep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ pyproject.toml | 2 +- substack/api.py | 35 ++++++++++++++++++++++++++++++++++- substack/post.py | 2 ++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c6a461..7409de5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsA image = api.get_image('image.png') post.add({"type": "captionedImage", "src": image.get("url")}) +# embed publication +embedded = api.publication_embed("https://jackio.substack.com/") +post.add({"type": "embeddedPublication", "url": embedded}) + draft = api.post_draft(post.get_draft()) # set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT) diff --git a/pyproject.toml b/pyproject.toml index fa48afc..9af917c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.1.14" +version = "0.1.15" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT" diff --git a/substack/api.py b/substack/api.py index faf3939..6e1dd53 100644 --- a/substack/api.py +++ b/substack/api.py @@ -128,7 +128,11 @@ def signin_for_pub(self, publication): response = self._session.get( f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}", ) - return Api._handle_response(response=response) + try: + output = Api._handle_response(response=response) + except SubstackRequestException as ex: + output = {} + return output def change_publication(self, publication): """ @@ -538,3 +542,32 @@ def get_sections(self): if p.get("hostname") in self.publication_url ] return sections[0] + + def publication_embed(self, url): + """ + + Args: + url: + + Returns: + + """ + return self.call("/publication/embed", "GET", url=url) + + def call(self, endpoint, method, **params): + """ + + Args: + endpoint: + method: + **params: + + Returns: + + """ + response = self._session.request( + method=method, + url=f"{self.publication_url}/{endpoint}", + params=params, + ) + return Api._handle_response(response=response) diff --git a/substack/post.py b/substack/post.py index bdae760..825baf5 100644 --- a/substack/post.py +++ b/substack/post.py @@ -84,6 +84,8 @@ def add(self, item: Dict): content = item.get("content") if item.get("type") == "captionedImage": self.captioned_image(**item) + elif item.get("type") == "embeddedPublication": + self.draft_body["content"][-1]["attrs"] = item.get("url") elif item.get("type") == "youtube2": self.youtube(item.get("src")) elif item.get("type") == "subscribeWidget": From 81c7feb97583aa8f3582cb6022bbf3a072e4c58d Mon Sep 17 00:00:00 2001 From: ma2za Date: Sun, 21 Dec 2025 14:18:10 +0100 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=94=A7=20Update=20dependencies=20and?= =?UTF-8?q?=20enhance=20cookie=20authentication=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++++---- examples/publish_post.py | 21 ++++++++-- poetry.lock | 16 ++++++-- pyproject.toml | 4 +- substack/api.py | 84 +++++++++++++++++++++++++++++++++++----- 5 files changed, 121 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7409de5..182e50d 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,19 @@ Set the following environment variables by creating a **.env** file: EMAIL= PASSWORD= + PUBLICATION_URL= # Optional: your publication URL + COOKIES_PATH= # Optional: path to cookies JSON file + COOKIES_STRING= # Optional: cookie string for authentication ## If you don't have a password -Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses +Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses your email address with a "magic" link. Set a password: -- Sign-out of Substack -- At the sign-in page click, "Sign in with password" under the `Email` text box +- Sign out of Substack +- At the sign-in page, click "Sign in with password" under the `Email` text box - Then choose, "Set a new password" The .env file will be ignored by git but always be careful. @@ -51,11 +54,12 @@ from substack.post import Post api = Api( email=os.getenv("EMAIL"), password=os.getenv("PASSWORD"), + publication_url=os.getenv("PUBLICATION_URL"), ) user_id = api.get_user_id() -# Switch Publications - The library defaults to your users primary publication. You can retrieve all your publications and change which one you want to use. +# Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use. # primary publication user_publication = api.get_user_primary_publication() @@ -98,9 +102,9 @@ post.add({"type": "embeddedPublication", "url": embedded}) draft = api.post_draft(post.get_draft()) -# set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT) -post.set_section("rick rolling", api.get_sections()) -api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) +# set section (can only be done after first posting the draft) +# post.set_section("rick rolling", api.get_sections()) +# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) api.prepublish_draft(draft.get("id")) @@ -120,3 +124,7 @@ Set up pre-commit ```shell pre-commit install ``` + +## Cookie Help + +To get a cookie string, after login, go to dev tools (F12), network tab, refresh and find one of the requests like subscription/unred/subscriptions, right click and copy as fetch (Node.js), paste somewhere and get the entire cookie string assigned to the cookie header and put it in the env variables as COOKIES_STRING, et voila! diff --git a/examples/publish_post.py b/examples/publish_post.py index 90a8916..a614201 100644 --- a/examples/publish_post.py +++ b/examples/publish_post.py @@ -22,21 +22,34 @@ parser.add_argument( "--publish", help="Publish the draft.", action="store_true", default=True ) + parser.add_argument( + "--cookies", + help="Path to cookies JSON file for authentication (optional, can also be set via COOKIES_PATH or COOKIES_STRING env vars).", + type=str, + default=None, + ) args = parser.parse_args() with open(args.post, "r") as fp: post_data = yaml.safe_load(fp) + cookies_path = args.cookies or os.getenv("COOKIES_PATH") + cookies_string = os.getenv("COOKIES_STRING") + api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), + email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None, + password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None, + cookies_path=cookies_path, + cookies_string=cookies_string, publication_url=os.getenv("PUBLICATION_URL"), ) + user_id = api.get_user_id() + post = Post( post_data.get("title"), post_data.get("subtitle", ""), - os.getenv("USER_ID"), + user_id, audience=post_data.get("audience", "everyone"), write_comment_permissions=post_data.get( "write_comment_permissions", "everyone" @@ -53,7 +66,7 @@ draft = api.post_draft(post.get_draft()) - post.set_section(post_data.get("section"), api.get_sections()) + # post.set_section(post_data.get("section"), api.get_sections()) api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) if args.publish: diff --git a/poetry.lock b/poetry.lock index e417fbc..4cbd3dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "certifi" @@ -6,6 +6,7 @@ version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, @@ -17,6 +18,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -116,6 +118,7 @@ version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, @@ -127,6 +130,7 @@ version = "0.21.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "python-dotenv-0.21.1.tar.gz", hash = "sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49"}, {file = "python_dotenv-0.21.1-py3-none-any.whl", hash = "sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"}, @@ -141,6 +145,7 @@ version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, @@ -160,6 +165,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -200,6 +206,7 @@ version = "2.31.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, @@ -221,18 +228,19 @@ version = "2.0.7" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.7" -content-hash = "35de245f8f35fd39d3f31a8a83c4097762df0a30fb5cefd8d84464e5ba058680" +content-hash = "78b8d3fc13bc69229788d547ec4f06e496cef199ea5dbf4ccaa84f736436cb84" diff --git a/pyproject.toml b/pyproject.toml index 9af917c..c755416 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.1.15" +version = "0.1.16" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT" @@ -23,7 +23,7 @@ python-dotenv = "^0.21.0" PyYAML = "^6.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] [build-system] diff --git a/substack/api.py b/substack/api.py index 6e1dd53..a4b51cb 100644 --- a/substack/api.py +++ b/substack/api.py @@ -9,7 +9,7 @@ import logging import os from datetime import datetime -from urllib.parse import urljoin +from urllib.parse import urljoin, unquote import requests @@ -35,6 +35,7 @@ def __init__( base_url=None, publication_url=None, debug=False, + cookies_string=None, ): """ @@ -49,6 +50,10 @@ def __init__( To re-use your session without logging in each time, you can save your cookies to a json file and then load them in the next session. Make sure to re-save your cookies, as they do update over time. + cookies_string + To re-use your session without logging in each time, you can provide cookies as a semicolon-separated + string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser + developer tools. base_url: The base URL to use to contact the Substack API. Defaults to https://substack.com/api/v1. @@ -68,11 +73,15 @@ def __init__( cookies = json.load(f) self._session.cookies.update(cookies) + elif cookies_string is not None: + cookies = self._parse_cookies_string(cookies_string) + self._session.cookies.update(cookies) + elif email is not None and password is not None: self.login(email, password) else: raise ValueError( - "Must provide email and password or cookies_path to authenticate." + "Must provide email and password, cookies_path, or cookies_string to authenticate." ) user_publication = None @@ -98,6 +107,31 @@ def __init__( # set the current publication to the users primary publication self.change_publication(user_publication) + @staticmethod + def _parse_cookies_string(cookies_string: str) -> dict: + """ + Parse a semicolon-separated cookie string into a dictionary. + + Args: + cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2") + + Returns: + A dictionary of cookie name-value pairs + """ + cookies = {} + for cookie_pair in cookies_string.split(';'): + cookie_pair = cookie_pair.strip() + if not cookie_pair: + continue + if '=' in cookie_pair: + key, value = cookie_pair.split('=', 1) + key = key.strip() + value = value.strip() + # URL decode the value (e.g., s%3A becomes s:) + value = unquote(value) + cookies[key] = value + return cookies + def login(self, email, password) -> dict: """ @@ -189,8 +223,8 @@ def get_publication_url(publication: dict) -> str: Args: publication: """ - custom_domain = publication["custom_domain"] - if not custom_domain: + custom_domain = publication.get("custom_domain", None) + if not custom_domain and not publication.get('custom_domain_optional', None): publication_url = f"https://{publication['subdomain']}.substack.com" else: publication_url = f"https://{custom_domain}" @@ -203,7 +237,31 @@ def get_user_primary_publication(self): """ profile = self.get_user_profile() - primary_publication = profile["primaryPublication"] + primary_publication = None + + # Try old API format first (backward compatibility) + if "primaryPublication" in profile and profile["primaryPublication"] is not None: + primary_publication = profile["primaryPublication"] + else: + # New API format: look for primary publication in publicationUsers + publication_users = profile.get("publicationUsers") + if publication_users is not None and len(publication_users) > 0: + # Find the publication where is_primary is True + for pub_user in publication_users: + if pub_user.get("is_primary", False): + primary_publication = pub_user.get("publication") + if primary_publication: + break + + # If no primary found, use the first publication + if primary_publication is None: + primary_publication = publication_users[0].get("publication") + + if primary_publication is None: + raise SubstackRequestException( + "Could not find primary publication in profile" + ) + primary_publication["publication_url"] = self.get_publication_url( primary_publication ) @@ -220,10 +278,18 @@ def get_user_publications(self): # Loop through users "publicationUsers" list, and return a list # of dictionaries of "name", and "subdomain", and "id" user_publications = [] - for publication in profile["publicationUsers"]: - pub = publication["publication"] - pub["publication_url"] = self.get_publication_url(pub) - user_publications.append(pub) + publication_users = profile.get("publicationUsers") + + if publication_users is None: + # If publicationUsers is None, return empty list or try to construct from other fields + # This maintains backward compatibility while handling new API format + return user_publications + + for publication in publication_users: + pub = publication.get("publication") + if pub is not None: + pub["publication_url"] = self.get_publication_url(pub) + user_publications.append(pub) return user_publications From 81c0fc29c6c624192b92e6615af76499d691b530 Mon Sep 17 00:00:00 2001 From: ma2za Date: Sun, 21 Dec 2025 14:45:56 +0100 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20Add=20Markdown=20support=20for?= =?UTF-8?q?=20post=20creation=20and=20example=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++ examples/publish_markdown.py | 111 +++++++++++++ substack/post.py | 310 ++++++++++++++++++++++++++++++++++- 3 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 examples/publish_markdown.py diff --git a/README.md b/README.md index 182e50d..fbbd991 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,16 @@ post.add({"type": "captionedImage", "src": image.get("url")}) embedded = api.publication_embed("https://jackio.substack.com/") post.add({"type": "embeddedPublication", "url": embedded}) +# create post from Markdown +markdown_content = """ +# My Heading + +This is a paragraph with **bold** and *italic* text. + +![Image Alt](https://example.com/image.jpg) +""" +post.from_markdown(markdown_content, api=api) + draft = api.post_draft(post.get_draft()) # set section (can only be done after first posting the draft) diff --git a/examples/publish_markdown.py b/examples/publish_markdown.py new file mode 100644 index 0000000..a0321ba --- /dev/null +++ b/examples/publish_markdown.py @@ -0,0 +1,111 @@ +""" +Example: Publishing a post from Markdown content + +This example demonstrates how to use the new Markdown support +to create Substack posts from Markdown files. + +This example reads from README.md to test the Markdown parsing. +""" + +import argparse +import os +from pathlib import Path +from dotenv import load_dotenv + +from substack import Api +from substack.post import Post + +load_dotenv() + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "-m", + "--markdown", + default="README.md", + required=False, + help="Markdown file to publish (default: README.md).", + type=str, + ) + parser.add_argument( + "--publish", help="Publish the draft.", action="store_true", default=False + ) + parser.add_argument( + "--cookies", + help="Path to cookies JSON file for authentication (optional, can also be set via COOKIES_PATH or COOKIES_STRING env vars).", + type=str, + default=None, + ) + args = parser.parse_args() + + cookies_path = args.cookies or os.getenv("COOKIES_PATH") + cookies_string = os.getenv("COOKIES_STRING") + + # Initialize API + api = Api( + email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None, + password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None, + cookies_path=cookies_path, + cookies_string=cookies_string, + publication_url=os.getenv("PUBLICATION_URL"), + ) + + user_id = api.get_user_id() + + # Determine the markdown file path + markdown_path = Path(args.markdown) + if not markdown_path.is_absolute(): + # If relative path, try relative to current directory first, then parent directory + if markdown_path.exists(): + pass # Use as-is + else: + # Try relative to parent directory (for README.md in project root) + markdown_path = Path(__file__).parent.parent / args.markdown + + if not markdown_path.exists(): + print(f"Error: Markdown file not found at {markdown_path}") + exit(1) + + with open(markdown_path, "r", encoding="utf-8") as f: + markdown_content = f.read() + + # Extract title from first heading (if it starts with #) + title = "Python Substack" + subtitle = "Markdown Test Post" + + lines = markdown_content.split("\n") + for line in lines: + if line.startswith("# "): + title = line[2:].strip() + break + elif line.startswith("#"): + # Skip badge lines and other non-title content + continue + + # Create a post + post = Post( + title=title, + subtitle=subtitle, + user_id=user_id, + ) + + # Parse and add Markdown content + print(f"Parsing Markdown from {markdown_path}...") + post.from_markdown(markdown_content, api=api) + + # Create draft + print("Creating draft...") + draft = api.post_draft(post.get_draft()) + + if args.publish: + print("Preparing to publish...") + api.prepublish_draft(draft.get("id")) + print("Publishing...") + api.publish_draft(draft.get("id")) + print("Post published successfully!") + else: + print(f"Draft created with ID: {draft.get('id')}") + print(f"Title: {title}") + print(f"Subtitle: {subtitle}") + print("Use --publish flag to publish the draft.") + diff --git a/substack/post.py b/substack/post.py index 825baf5..de262ee 100644 --- a/substack/post.py +++ b/substack/post.py @@ -5,13 +5,103 @@ """ import json -from typing import Dict +import re +from typing import Dict, List -__all__ = ["Post"] +__all__ = ["Post", "parse_inline"] from substack.exceptions import SectionNotExistsException +def parse_inline(text: str) -> List[Dict]: + """ + Convert inline Markdown in a text string into a list of tokens + for use in the post content. + + Supported formatting: + - **Bold**: Text wrapped in double asterisks. + - *Italic*: Text wrapped in single asterisks. + - [Links]: Text wrapped in square brackets followed by URL in parentheses. + + Args: + text: Text string containing inline Markdown formatting. + + Returns: + List of token dictionaries with content and marks. + + Example: + >>> parse_inline("This is **bold** and this is [a link](https://example.com)") + [{'content': 'This is '}, {'content': 'bold', 'marks': [{'type': 'strong'}]}, {'content': ' and this is '}, {'content': 'a link', 'marks': [{'type': 'link', 'attrs': {'href': 'https://example.com'}}]}] + """ + if not text: + return [] + + tokens = [] + # Process text character by character to handle nested formatting + # We'll use regex to find all markdown patterns, then process them in order + + # Find all markdown patterns: links, bold, italic + # Pattern order: links first (to avoid conflicts), then bold, then italic + link_pattern = r'\[([^\]]+)\]\(([^)]+)\)' + bold_pattern = r'\*\*([^*]+)\*\*' + italic_pattern = r'(? 0 and text[match.start()-1:match.start()+1] != "![": + matches.append((match.start(), match.end(), "link", match.group(1), match.group(2))) + + for match in re.finditer(bold_pattern, text): + # Check if this range is already covered by a link + if not any(start <= match.start() < end for start, end, _, _, _ in matches): + matches.append((match.start(), match.end(), "bold", match.group(1), None)) + + for match in re.finditer(italic_pattern, text): + # Check if this range is already covered by a link or bold + if not any(start <= match.start() < end for start, end, _, _, _ in matches): + matches.append((match.start(), match.end(), "italic", match.group(1), None)) + + # Sort matches by position + matches.sort(key=lambda x: x[0]) + + # Build tokens + last_pos = 0 + for start, end, match_type, content, url in matches: + # Add text before this match + if start > last_pos: + tokens.append({"content": text[last_pos:start]}) + + # Add the formatted content + if match_type == "link": + tokens.append({ + "content": content, + "marks": [{"type": "link", "attrs": {"href": url}}] + }) + elif match_type == "bold": + tokens.append({ + "content": content, + "marks": [{"type": "strong"}] + }) + elif match_type == "italic": + tokens.append({ + "content": content, + "marks": [{"type": "em"}] + }) + + last_pos = end + + # Add remaining text + if last_pos < len(text): + tokens.append({"content": text[last_pos:]}) + + # Filter out empty tokens + tokens = [t for t in tokens if t.get("content")] + + return tokens + + class Post: """ @@ -90,6 +180,8 @@ def add(self, item: Dict): self.youtube(item.get("src")) elif item.get("type") == "subscribeWidget": self.subscribe_with_caption(item.get("message")) + elif item.get("type") == "codeBlock": + self.code_block(item.get("content"), item.get("attrs", {})) else: if content is not None: self.add_complex_text(content) @@ -329,3 +421,217 @@ def youtube(self, value: str): content_attrs.update({"videoId": value}) self.draft_body["content"][-1]["attrs"] = content_attrs return self + + def code_block(self, content, attrs=None): + """ + Add code block to post. + + Args: + content: String containing code or list of text nodes + attrs: Optional attributes like language + + Returns: + + """ + if attrs is None: + attrs = {} + + # Handle content - can be list of text nodes or a string + if isinstance(content, str): + # Convert string to list of text nodes + code_content = [{"type": "text", "text": content}] + elif isinstance(content, list): + code_content = content + else: + code_content = [] + + # Set up the code block structure + code_block = self.draft_body["content"][-1] + code_block["content"] = code_content + if attrs: + code_block["attrs"] = attrs + + return self + + def from_markdown(self, markdown_content: str, api=None): + """ + Parse Markdown content and add it to the post. + + Supported Markdown features: + - Headings: Lines starting with '#' characters (1-6 levels) + - Images: Markdown image syntax ![Alt](URL) + - Linked images: [![Alt](image_url)](link_url) - images that are also links + - Links: [text](url) - inline links in paragraphs + - Code blocks: Fenced code blocks with ```language or ``` + - Paragraphs: Regular text blocks + - Bullet lists: Lines starting with '*' or '-' + - Inline formatting: **bold** and *italic* within paragraphs + + Args: + markdown_content: Markdown string to parse and add to the post. + api: Optional Api instance for uploading local images. If provided, + local image paths will be uploaded via api.get_image(). + + Returns: + Self for method chaining. + + Example: + >>> post = Post("Title", "Subtitle", user_id) + >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).") + """ + lines = markdown_content.split("\n") + blocks = [] + current_block: List[str] = [] + in_code_block = False + code_block_language = None + + for line in lines: + # Check for fenced code block start/end + if line.strip().startswith("```"): + if in_code_block: + # End of code block + if current_block: + blocks.append({ + "type": "code", + "language": code_block_language, + "content": "\n".join(current_block) + }) + current_block = [] + in_code_block = False + code_block_language = None + else: + # Start of code block + if current_block: + blocks.append({"type": "text", "content": "\n".join(current_block)}) + current_block = [] + # Extract language if specified + language = line.strip()[3:].strip() + code_block_language = language if language else None + in_code_block = True + continue + + if in_code_block: + # Inside code block - collect lines as-is + current_block.append(line) + else: + # Regular content + if line.strip() == "": + # Empty line - end current block if it has content + if current_block: + blocks.append({"type": "text", "content": "\n".join(current_block)}) + current_block = [] + else: + current_block.append(line) + + # Add any remaining content + if current_block: + if in_code_block: + blocks.append({ + "type": "code", + "language": code_block_language, + "content": "\n".join(current_block) + }) + else: + blocks.append({"type": "text", "content": "\n".join(current_block)}) + + # Process blocks + for block in blocks: + if block["type"] == "code": + # Add code block + code_content = block.get("content", "").strip() + if code_content: + # Substack uses "codeBlock" type + code_attrs = {} + if block.get("language"): + code_attrs["language"] = block["language"] + self.add({ + "type": "codeBlock", + "content": code_content, # Pass as string, code_block method will handle it + "attrs": code_attrs + }) + else: + # Process text block + text_content = block.get("content", "").strip() + if not text_content: + continue + + # Process headings (lines starting with '#' characters) + if text_content.startswith("#"): + level = len(text_content) - len(text_content.lstrip("#")) + heading_text = text_content.lstrip("#").strip() + if heading_text: # Only add if there's actual text + self.heading(content=heading_text, level=min(level, 6)) + + # Process images using Markdown image syntax: ![Alt](URL) + # Also handle linked images: [![Alt](image_url)](link_url) + elif text_content.startswith("!") or (text_content.startswith("[") and "![" in text_content): + # Check for linked image first: [![alt](img)](link) + linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content) + if linked_image_match: + # Linked image - create image with href + alt_text = linked_image_match.group(1) + image_url = linked_image_match.group(2) + link_url = linked_image_match.group(3) + + # Adjust image URL if it starts with a slash + image_url = image_url[1:] if image_url.startswith("/") else image_url + + # If api is provided and image_url is a local file, upload it + if api is not None: + try: + image = api.get_image(image_url) + image_url = image.get("url") + except Exception: + # If upload fails, use original URL + pass + + self.add({ + "type": "captionedImage", + "src": image_url, + "alt": alt_text, + "href": link_url + }) + else: + # Regular image: ![Alt](URL) + match = re.match(r"!\[.*?\]\((.*?)\)", text_content) + if match: + image_url = match.group(1) + # Adjust image URL if it starts with a slash + image_url = image_url[1:] if image_url.startswith("/") else image_url + + # If api is provided and image_url is a local file, upload it + if api is not None: + try: + image = api.get_image(image_url) + image_url = image.get("url") + except Exception: + # If upload fails, use original URL + pass + + self.add({"type": "captionedImage", "src": image_url}) + + # Process paragraphs or bullet lists + else: + if "\n" in text_content: + # Process each line separately (for bullet lists) + for line in text_content.split("\n"): + line = line.strip() + if not line: + continue + # Remove bullet marker if present + if line.startswith("* "): + line = line[2:].strip() + elif line.startswith("- "): + line = line[2:].strip() + elif line.startswith("*") and not line.startswith("**"): + line = line[1:].strip() + + if line: + tokens = parse_inline(line) + self.add({"type": "paragraph", "content": tokens}) + else: + # Single paragraph + tokens = parse_inline(text_content) + self.add({"type": "paragraph", "content": tokens}) + + return self From faa639b0039e03a30bdf152a954e4297c86fcf1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:47:31 +0100 Subject: [PATCH 6/8] Bump certifi from 2023.11.17 to 2024.7.4 (#34) Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4. - [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04) --- updated-dependencies: - dependency-name: certifi dependency-version: 2024.7.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4cbd3dc..d57d33c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,15 +1,15 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] From 8407ab2d21c54f5a06892dc026e3c714d10f06e6 Mon Sep 17 00:00:00 2001 From: ma2za Date: Sun, 21 Dec 2025 15:00:47 +0100 Subject: [PATCH 7/8] Update dependencies in poetry.lock and pyproject.toml; bump Python version to ^3.9, certifi to 2025.11.12, charset-normalizer to 3.4.4, requests to 2.32.5, and urllib3 to 2.6.2. Adjusted README.md to remove outdated Python version badge. --- README.md | 1 - poetry.lock | 384 +++++++++++++++++++++++++++---------------------- pyproject.toml | 2 +- 3 files changed, 217 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index fbbd991..e41466b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ This is an unofficial library providing a Python interface for [Substack](https://substack.com/). I am in no way affiliated with Substack. -[![Python](https://img.shields.io/pypi/pyversions/fastapi.svg?color=%2334D058)](https://www.python.org/downloads/) [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack) ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg) --- diff --git a/poetry.lock b/poetry.lock index d57d33c..ce3ef34 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,129 +1,155 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "certifi" -version = "2024.7.4" +version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] name = "idna" -version = "3.6" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "python-dotenv" version = "0.21.1" @@ -141,80 +167,102 @@ cli = ["click (>=5.0)"] [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -224,23 +272,23 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "urllib3" -version = "2.0.7" +version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, - {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, + {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, + {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" -python-versions = "^3.7" -content-hash = "78b8d3fc13bc69229788d547ec4f06e496cef199ea5dbf4ccaa84f736436cb84" +python-versions = "^3.9" +content-hash = "eca02f6ded24311e6a3f6249289dfb5a139fdf1239637acbc90a5b771953df34" diff --git a/pyproject.toml b/pyproject.toml index c755416..e5f937c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ homepage = "https://github.com/ma2za/python-substack" keywords = ["substack"] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.9" requests = "^2.31.0" python-dotenv = "^0.21.0" From 06395cdd57f09ba62c79f5cdd51900193cda5dbc Mon Sep 17 00:00:00 2001 From: ma2za Date: Sun, 21 Dec 2025 15:05:51 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=200.1.17?= =?UTF-8?q?;=20enhance=20README=20with=20authentication=20methods=20and=20?= =?UTF-8?q?YAML=20post=20management=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e41466b..9f6844b 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,49 @@ The .env file will be ignored by git but always be careful. Check out the examples folder for some examples 😃 🚀 +## Basic Authentication + ```python import os +from dotenv import load_dotenv from substack import Api from substack.post import Post +load_dotenv() + +# Authenticate with email and password api = Api( email=os.getenv("EMAIL"), password=os.getenv("PASSWORD"), publication_url=os.getenv("PUBLICATION_URL"), ) +``` + +## Cookie-based Authentication +You can also authenticate using cookies instead of email/password: + +```python +import os +from dotenv import load_dotenv + +from substack import Api + +load_dotenv() + +# Authenticate with cookies (alternative to email/password) +api = Api( + cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file + # OR + cookies_string=os.getenv("COOKIES_STRING"), # Cookie string + publication_url=os.getenv("PUBLICATION_URL"), +) +``` + +## Creating and Publishing Posts + +```python user_id = api.get_user_id() # Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use. @@ -68,12 +99,22 @@ user_publications = api.get_user_publications() # This step is only necessary if you are not using your primary publication # api.change_publication(user_publication) +# Create a post with basic settings post = Post( title="How to publish a Substack post using the Python API", subtitle="This post was published using the Python API", user_id=user_id ) +# Create a post with audience and comment permissions +post = Post( + title="My Post Title", + subtitle="My Post Subtitle", + user_id=user_id, + audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free" + write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone" +) + post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'}) # bolden text @@ -120,6 +161,85 @@ api.prepublish_draft(draft.get("id")) api.publish_draft(draft.get("id")) ``` +## Loading Posts from YAML Files + +You can define your posts in YAML files for easier management: + +```python +import yaml +import os +from dotenv import load_dotenv + +from substack import Api +from substack.post import Post + +load_dotenv() + +# Load post data from YAML file +with open("draft.yaml", "r") as fp: + post_data = yaml.safe_load(fp) + +# Authenticate (using cookies or email/password) +cookies_path = os.getenv("COOKIES_PATH") +cookies_string = os.getenv("COOKIES_STRING") + +api = Api( + email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None, + password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None, + cookies_path=cookies_path, + cookies_string=cookies_string, + publication_url=os.getenv("PUBLICATION_URL"), +) + +user_id = api.get_user_id() + +# Create post from YAML data +post = Post( + post_data.get("title"), + post_data.get("subtitle", ""), + user_id, + audience=post_data.get("audience", "everyone"), + write_comment_permissions=post_data.get("write_comment_permissions", "everyone"), +) + +# Add body content from YAML +body = post_data.get("body", {}) +for _, item in body.items(): + # Handle local images - upload them first + if item.get("type") == "captionedImage" and not item.get("src").startswith("http"): + image = api.get_image(item.get("src")) + item.update({"src": image.get("url")}) + post.add(item) + +draft = api.post_draft(post.get_draft()) +api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) + +# Publish the draft +api.prepublish_draft(draft.get("id")) +api.publish_draft(draft.get("id")) +``` + +Example YAML structure: + +```yaml +title: "My Post Title" +subtitle: "My Post Subtitle" +audience: "everyone" # everyone, only_paid, founding, only_free +write_comment_permissions: "everyone" # none, only_paid, everyone +section: "my-section" +body: + 0: + type: "heading" + level: 1 + content: "Introduction" + 1: + type: "paragraph" + content: "This is a paragraph." + 2: + type: "captionedImage" + src: "local_image.jpg" # Local images will be uploaded automatically +``` + # Contributing Install pre-commit: diff --git a/pyproject.toml b/pyproject.toml index e5f937c..cec993c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.1.16" +version = "0.1.17" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT"