python-substack 0.1.25__py3-none-any.whl → 0.1.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_substack-0.1.25.dist-info → python_substack-0.1.27.dist-info}/METADATA +107 -9
- python_substack-0.1.27.dist-info/RECORD +13 -0
- {python_substack-0.1.25.dist-info → python_substack-0.1.27.dist-info}/WHEEL +1 -1
- {python_substack-0.1.25.dist-info → python_substack-0.1.27.dist-info}/entry_points.txt +1 -0
- substack/__init__.py +2 -2
- substack/api.py +21 -5
- substack/cli.py +409 -4
- substack/mdrender.py +39 -1
- substack/nodes.py +35 -0
- python_substack-0.1.25.dist-info/RECORD +0 -13
- {python_substack-0.1.25.dist-info → python_substack-0.1.27.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: A Python
|
|
3
|
+
Version: 0.1.27
|
|
4
|
+
Summary: A Python SDK and CLI for managing Substack publications and drafts.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Keywords: substack
|
|
7
|
+
Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
|
|
8
8
|
Author: Paolo Mazza
|
|
9
9
|
Author-email: mazzapaolo2019@gmail.com
|
|
10
10
|
Requires-Python: >=3.10,<4.0
|
|
@@ -24,7 +24,7 @@ Provides-Extra: mcp
|
|
|
24
24
|
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
25
25
|
Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
|
|
26
26
|
Requires-Dist: markdown-it-py (>=3.0,<4.0)
|
|
27
|
-
Requires-Dist: mdit-py-plugins (>=0.
|
|
27
|
+
Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
|
|
28
28
|
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
29
29
|
Requires-Dist: requests (>=2.32.0,<3.0.0)
|
|
30
30
|
Project-URL: Changelog, https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md
|
|
@@ -35,13 +35,20 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
|
|
36
36
|
# Python Substack
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
|
|
39
39
|
|
|
40
|
+
[](https://pypi.org/project/python-substack/)
|
|
41
|
+
[](https://pypi.org/project/python-substack/)
|
|
42
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
|
|
43
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
|
|
44
|
+
[](LICENSE)
|
|
40
45
|
[](https://pepy.tech/project/python-substack)
|
|
41
|
-

|
|
42
46
|
|
|
43
47
|
## Features
|
|
44
48
|
|
|
49
|
+
- Inspect authentication and publication status from the terminal.
|
|
50
|
+
- List publications and inspect, schedule, publish, or delete drafts.
|
|
51
|
+
- Use stable JSON output in scripts and automation.
|
|
45
52
|
- Create drafts and publish posts from Python.
|
|
46
53
|
- Convert Markdown into Substack's editor document format.
|
|
47
54
|
- Upload local images while rendering Markdown.
|
|
@@ -78,6 +85,76 @@ Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_
|
|
|
78
85
|
|
|
79
86
|
Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
|
|
80
87
|
|
|
88
|
+
## CLI Operations
|
|
89
|
+
|
|
90
|
+
Create a draft from Markdown without publishing it:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
substack drafts create post.md
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Set metadata and repeat `--tag` to attach multiple tags:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
substack --json drafts create post.md \
|
|
100
|
+
--title "My Post" \
|
|
101
|
+
--subtitle "Optional subtitle" \
|
|
102
|
+
--tag python \
|
|
103
|
+
--tag substack \
|
|
104
|
+
--slug my-post \
|
|
105
|
+
--search-engine-title "SEO title" \
|
|
106
|
+
--search-engine-description "SEO description"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Creation and publishing are intentionally separate. Use the returned draft ID
|
|
110
|
+
when the draft is ready:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
substack drafts create post.md
|
|
114
|
+
substack drafts publish 12345 --no-send
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Check authentication, the selected publication, and subscriber count:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
substack status
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
List available publications or target one without changing `.env`:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
substack publications list
|
|
127
|
+
substack --publication-url https://example.substack.com drafts list
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
List and inspect drafts:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
substack drafts list --limit 10
|
|
134
|
+
substack drafts get 12345
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
|
|
141
|
+
substack drafts unschedule 12345
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
substack drafts publish 12345 --no-send
|
|
148
|
+
substack drafts delete 12345 --yes
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
substack --json drafts list
|
|
155
|
+
substack --cookies cookies.json --json status
|
|
156
|
+
```
|
|
157
|
+
|
|
81
158
|
## Quickstart
|
|
82
159
|
|
|
83
160
|
```python
|
|
@@ -113,7 +190,9 @@ print(result["draft"]["id"])
|
|
|
113
190
|
|
|
114
191
|
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
|
|
115
192
|
|
|
116
|
-
## CLI
|
|
193
|
+
## Legacy Content Publishing CLI
|
|
194
|
+
|
|
195
|
+
The existing standalone commands remain supported for compatibility.
|
|
117
196
|
|
|
118
197
|
Check authentication without creating a draft:
|
|
119
198
|
|
|
@@ -290,7 +369,7 @@ Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and foo
|
|
|
290
369
|
)
|
|
291
370
|
```
|
|
292
371
|
|
|
293
|
-
Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, and
|
|
372
|
+
Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, superscript, subscript, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and callouts. See [docs/markdown.md](docs/markdown.md) for the full reference with examples.
|
|
294
373
|
|
|
295
374
|
When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
|
|
296
375
|
|
|
@@ -371,9 +450,28 @@ pre-commit install
|
|
|
371
450
|
pytest
|
|
372
451
|
```
|
|
373
452
|
|
|
374
|
-
|
|
453
|
+
Run the offline suite with:
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
pytest -m "not live"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Live Substack tests are not part of normal CI. They are opt-in and require
|
|
460
|
+
configured credentials:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
RUN_SUBSTACK_E2E=1 pytest -m live
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
The CLI operations smoke tests are separately opt-in. They create, inspect, and
|
|
467
|
+
delete disposable drafts but never publish them:
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
RUN_SUBSTACK_CLI_E2E=1 pytest -m live tests/substack/test_cli_end_to_end.py
|
|
471
|
+
```
|
|
375
472
|
|
|
376
473
|
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
474
|
+
The maintainer release process is documented in [docs/releasing.md](docs/releasing.md).
|
|
377
475
|
|
|
378
476
|
## Disclaimer
|
|
379
477
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
substack/__init__.py,sha256=Wd16zqqfA6bYLntSyq_dnRIFxosqgzjbyIVcJigGQ_A,416
|
|
2
|
+
substack/api.py,sha256=tuxefoBuOvBn4Ww2VCbqkWCuX6qPOg7YM6EsLheBpV4,23213
|
|
3
|
+
substack/cli.py,sha256=JBCsCdmthjzTeDpOYBLeVKOdNYjBrPuY1o_CIsAIekk,21712
|
|
4
|
+
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
5
|
+
substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
|
|
6
|
+
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
7
|
+
substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
|
|
8
|
+
substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
|
|
9
|
+
python_substack-0.1.27.dist-info/METADATA,sha256=-yH6Uq4uhgO8tUYONxf3EkMMpvXd7--dxEAMkYsHTMc,11738
|
|
10
|
+
python_substack-0.1.27.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
11
|
+
python_substack-0.1.27.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
12
|
+
python_substack-0.1.27.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
+
python_substack-0.1.27.dist-info/RECORD,,
|
substack/__init__.py
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.27"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
|
-
__description__ = "A Python
|
|
9
|
+
__description__ = "A Python SDK and CLI for managing Substack publications and drafts"
|
|
10
10
|
|
|
11
11
|
from .api import Api
|
substack/api.py
CHANGED
|
@@ -12,6 +12,7 @@ from datetime import datetime
|
|
|
12
12
|
from urllib.parse import unquote, urljoin
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
15
16
|
|
|
16
17
|
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
17
18
|
|
|
@@ -65,6 +66,18 @@ class Api:
|
|
|
65
66
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
66
67
|
|
|
67
68
|
self._session = requests.Session()
|
|
69
|
+
retry = Retry(
|
|
70
|
+
total=4,
|
|
71
|
+
status=4,
|
|
72
|
+
backoff_factor=1,
|
|
73
|
+
status_forcelist=(429,),
|
|
74
|
+
allowed_methods=frozenset({"GET", "DELETE"}),
|
|
75
|
+
respect_retry_after_header=True,
|
|
76
|
+
raise_on_status=False,
|
|
77
|
+
)
|
|
78
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
79
|
+
self._session.mount("http://", adapter)
|
|
80
|
+
self._session.mount("https://", adapter)
|
|
68
81
|
|
|
69
82
|
# Load cookies from file if provided
|
|
70
83
|
# Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
|
|
@@ -346,7 +359,10 @@ class Api:
|
|
|
346
359
|
f"{self.publication_url}/publication_launch_checklist"
|
|
347
360
|
)
|
|
348
361
|
|
|
349
|
-
|
|
362
|
+
data = Api._handle_response(response=response)
|
|
363
|
+
if "subscriberCount" in data:
|
|
364
|
+
return data["subscriberCount"]
|
|
365
|
+
return len(data["subscribers"])
|
|
350
366
|
|
|
351
367
|
def get_published_posts(
|
|
352
368
|
self, offset=0, limit=25, order_by="post_date", order_direction="desc"
|
|
@@ -553,8 +569,8 @@ class Api:
|
|
|
553
569
|
|
|
554
570
|
"""
|
|
555
571
|
response = self._session.post(
|
|
556
|
-
f"{self.publication_url}/drafts/{draft}/
|
|
557
|
-
json={"
|
|
572
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release",
|
|
573
|
+
json={"trigger_at": draft_datetime.isoformat()},
|
|
558
574
|
)
|
|
559
575
|
return Api._handle_response(response=response)
|
|
560
576
|
|
|
@@ -567,8 +583,8 @@ class Api:
|
|
|
567
583
|
Returns:
|
|
568
584
|
|
|
569
585
|
"""
|
|
570
|
-
response = self._session.
|
|
571
|
-
f"{self.publication_url}/drafts/{draft}/
|
|
586
|
+
response = self._session.delete(
|
|
587
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release"
|
|
572
588
|
)
|
|
573
589
|
return Api._handle_response(response=response)
|
|
574
590
|
|
substack/cli.py
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
4
6
|
from pathlib import Path
|
|
7
|
+
from urllib.parse import urljoin
|
|
5
8
|
|
|
6
9
|
import yaml
|
|
7
10
|
from dotenv import load_dotenv
|
|
8
11
|
|
|
9
|
-
from substack import Api
|
|
12
|
+
from substack import Api, __version__
|
|
13
|
+
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
10
14
|
from substack.post import Post
|
|
11
15
|
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
class CLIUsageError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _api_from_env(cookies_path=None, publication_url=None):
|
|
14
22
|
load_dotenv()
|
|
15
23
|
|
|
16
24
|
cookies_path = cookies_path or os.getenv("COOKIES_PATH")
|
|
17
25
|
cookies_string = os.getenv("COOKIES_STRING")
|
|
26
|
+
publication_url = publication_url or os.getenv("PUBLICATION_URL")
|
|
18
27
|
|
|
19
28
|
if cookies_path or cookies_string:
|
|
20
29
|
return Api(
|
|
21
30
|
cookies_path=cookies_path,
|
|
22
31
|
cookies_string=cookies_string,
|
|
23
|
-
publication_url=
|
|
32
|
+
publication_url=publication_url,
|
|
24
33
|
)
|
|
25
34
|
|
|
26
35
|
return Api(
|
|
27
36
|
email=os.getenv("EMAIL"),
|
|
28
37
|
password=os.getenv("PASSWORD"),
|
|
29
|
-
publication_url=
|
|
38
|
+
publication_url=publication_url,
|
|
30
39
|
)
|
|
31
40
|
|
|
32
41
|
|
|
@@ -56,6 +65,402 @@ def _print_result(result):
|
|
|
56
65
|
print(json.dumps({"draft_id": draft.get("id"), "draft": draft}, indent=2))
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
def _print_json(value, stream=None):
|
|
69
|
+
print(json.dumps(value, indent=2, default=str), file=stream)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _display(value):
|
|
73
|
+
if value is None or value == "":
|
|
74
|
+
return "-"
|
|
75
|
+
return str(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_rows(headers, rows):
|
|
79
|
+
rows = [[_display(value) for value in row] for row in rows]
|
|
80
|
+
widths = [len(header) for header in headers]
|
|
81
|
+
for row in rows:
|
|
82
|
+
for index, value in enumerate(row):
|
|
83
|
+
widths[index] = max(widths[index], len(value))
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
|
87
|
+
)
|
|
88
|
+
print(" ".join("-" * width for width in widths))
|
|
89
|
+
for row in rows:
|
|
90
|
+
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _identity(profile):
|
|
94
|
+
return (
|
|
95
|
+
profile.get("email")
|
|
96
|
+
or profile.get("name")
|
|
97
|
+
or profile.get("handle")
|
|
98
|
+
or str(profile.get("id", "unknown"))
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _selected_publication(api, publications):
|
|
103
|
+
for publication in publications:
|
|
104
|
+
publication_url = publication.get("publication_url")
|
|
105
|
+
if (
|
|
106
|
+
publication_url
|
|
107
|
+
and urljoin(publication_url, "api/v1") == api.publication_url
|
|
108
|
+
):
|
|
109
|
+
return publication
|
|
110
|
+
return {"publication_url": api.publication_url.removesuffix("/api/v1")}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_schedule(value):
|
|
114
|
+
normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
115
|
+
try:
|
|
116
|
+
scheduled_at = datetime.fromisoformat(normalized)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise CLIUsageError("--at must be a valid ISO 8601 timestamp") from exc
|
|
119
|
+
if scheduled_at.utcoffset() is None:
|
|
120
|
+
raise CLIUsageError("--at must include a timezone offset or Z")
|
|
121
|
+
return scheduled_at
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _confirm(action, target, yes, json_output):
|
|
125
|
+
if yes:
|
|
126
|
+
return
|
|
127
|
+
if json_output or not sys.stdin.isatty():
|
|
128
|
+
raise CLIUsageError(f"{action} requires --yes in non-interactive or JSON mode")
|
|
129
|
+
try:
|
|
130
|
+
confirmed = input(f"{action} draft {target}? [y/N] ").strip().lower()
|
|
131
|
+
except EOFError as exc:
|
|
132
|
+
raise CLIUsageError(f"{action} requires confirmation or --yes") from exc
|
|
133
|
+
if confirmed not in {"y", "yes"}:
|
|
134
|
+
raise CLIUsageError("Cancelled")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _redact(message):
|
|
138
|
+
redacted = str(message)
|
|
139
|
+
for name in ("EMAIL", "PASSWORD", "COOKIES_STRING"):
|
|
140
|
+
secret = os.getenv(name)
|
|
141
|
+
if secret:
|
|
142
|
+
redacted = redacted.replace(secret, "[redacted]")
|
|
143
|
+
return redacted
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _error_payload(exc):
|
|
147
|
+
if isinstance(exc, SubstackAPIException):
|
|
148
|
+
return {
|
|
149
|
+
"error": {
|
|
150
|
+
"type": "api_error",
|
|
151
|
+
"message": _redact(exc.message),
|
|
152
|
+
"status_code": exc.status_code,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if isinstance(exc, SubstackRequestException):
|
|
156
|
+
return {
|
|
157
|
+
"error": {
|
|
158
|
+
"type": "request_error",
|
|
159
|
+
"message": _redact(exc.message),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if isinstance(exc, OSError):
|
|
163
|
+
return {"error": {"type": "io_error", "message": _redact(exc)}}
|
|
164
|
+
return {"error": {"type": "configuration_error", "message": _redact(exc)}}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _print_error(exc, json_output):
|
|
168
|
+
payload = _error_payload(exc)
|
|
169
|
+
if json_output:
|
|
170
|
+
_print_json(payload, stream=sys.stderr)
|
|
171
|
+
else:
|
|
172
|
+
print(f"Error: {payload['error']['message']}", file=sys.stderr)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _print_usage_error(exc, json_output):
|
|
176
|
+
if json_output:
|
|
177
|
+
_print_json(
|
|
178
|
+
{"error": {"type": "usage_error", "message": str(exc)}},
|
|
179
|
+
stream=sys.stderr,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _status(api, args):
|
|
186
|
+
profile = api.get_user_profile()
|
|
187
|
+
publications = api.get_user_publications()
|
|
188
|
+
selected = _selected_publication(api, publications)
|
|
189
|
+
subscriber_count = api.get_publication_subscriber_count()
|
|
190
|
+
result = {
|
|
191
|
+
"status": {
|
|
192
|
+
"identity": _identity(profile),
|
|
193
|
+
"auth_method": _auth_method(args.cookies),
|
|
194
|
+
"subscriber_count": subscriber_count,
|
|
195
|
+
},
|
|
196
|
+
"profile": profile,
|
|
197
|
+
"publication": selected,
|
|
198
|
+
"publications": publications,
|
|
199
|
+
}
|
|
200
|
+
if args.json_output:
|
|
201
|
+
_print_json(result)
|
|
202
|
+
else:
|
|
203
|
+
print(f"Authenticated as: {result['status']['identity']}")
|
|
204
|
+
print(f"Auth method: {result['status']['auth_method']}")
|
|
205
|
+
print(f"Publication: {_display(selected.get('name'))}")
|
|
206
|
+
print(f"Publication URL: {_display(selected.get('publication_url'))}")
|
|
207
|
+
print(f"Subscribers: {subscriber_count}")
|
|
208
|
+
print(f"Available publications: {len(publications)}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _publications_list(api, args):
|
|
212
|
+
publications = api.get_user_publications()
|
|
213
|
+
if args.json_output:
|
|
214
|
+
_print_json({"publications": publications, "count": len(publications)})
|
|
215
|
+
else:
|
|
216
|
+
_print_rows(
|
|
217
|
+
["ID", "NAME", "SUBDOMAIN", "URL"],
|
|
218
|
+
[
|
|
219
|
+
[
|
|
220
|
+
publication.get("id"),
|
|
221
|
+
publication.get("name"),
|
|
222
|
+
publication.get("subdomain"),
|
|
223
|
+
publication.get("publication_url"),
|
|
224
|
+
]
|
|
225
|
+
for publication in publications
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _drafts_list(api, args):
|
|
231
|
+
if args.offset < 0:
|
|
232
|
+
raise CLIUsageError("--offset must be zero or greater")
|
|
233
|
+
if args.limit < 1:
|
|
234
|
+
raise CLIUsageError("--limit must be greater than zero")
|
|
235
|
+
drafts = api.get_drafts(filter=args.filter, offset=args.offset, limit=args.limit)
|
|
236
|
+
if args.json_output:
|
|
237
|
+
_print_json(
|
|
238
|
+
{
|
|
239
|
+
"drafts": drafts,
|
|
240
|
+
"count": len(drafts),
|
|
241
|
+
"filter": args.filter,
|
|
242
|
+
"offset": args.offset,
|
|
243
|
+
"limit": args.limit,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
_print_rows(
|
|
248
|
+
["ID", "TITLE", "STATUS", "SCHEDULED"],
|
|
249
|
+
[
|
|
250
|
+
[
|
|
251
|
+
draft.get("id"),
|
|
252
|
+
draft.get("draft_title") or draft.get("title"),
|
|
253
|
+
draft.get("type") or draft.get("status") or "draft",
|
|
254
|
+
draft.get("post_date"),
|
|
255
|
+
]
|
|
256
|
+
for draft in drafts
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _drafts_get(api, args):
|
|
262
|
+
draft = api.get_draft(args.draft_id)
|
|
263
|
+
if args.json_output:
|
|
264
|
+
_print_json({"draft": draft})
|
|
265
|
+
else:
|
|
266
|
+
fields = [
|
|
267
|
+
("ID", draft.get("id")),
|
|
268
|
+
("Title", draft.get("draft_title") or draft.get("title")),
|
|
269
|
+
("Subtitle", draft.get("draft_subtitle") or draft.get("subtitle")),
|
|
270
|
+
("Status", draft.get("type") or draft.get("status") or "draft"),
|
|
271
|
+
("Slug", draft.get("slug")),
|
|
272
|
+
("Scheduled", draft.get("post_date")),
|
|
273
|
+
("Audience", draft.get("audience")),
|
|
274
|
+
]
|
|
275
|
+
for label, value in fields:
|
|
276
|
+
print(f"{label}: {_display(value)}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _drafts_create(api, args):
|
|
280
|
+
markdown_path = Path(args.markdown_file)
|
|
281
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
282
|
+
title = args.title or _title_from_markdown(markdown, markdown_path.stem)
|
|
283
|
+
result = api.create_draft_from_markdown(
|
|
284
|
+
title=title,
|
|
285
|
+
markdown=markdown,
|
|
286
|
+
subtitle=args.subtitle,
|
|
287
|
+
audience=args.audience,
|
|
288
|
+
write_comment_permissions=args.write_comment_permissions,
|
|
289
|
+
search_engine_title=args.search_engine_title,
|
|
290
|
+
search_engine_description=args.search_engine_description,
|
|
291
|
+
slug=args.slug,
|
|
292
|
+
draft_section_id=args.draft_section_id,
|
|
293
|
+
tags=args.tags,
|
|
294
|
+
prepublish=False,
|
|
295
|
+
publish=False,
|
|
296
|
+
)
|
|
297
|
+
draft = result["draft"]
|
|
298
|
+
payload = {
|
|
299
|
+
"action": "create",
|
|
300
|
+
"draft_id": draft.get("id"),
|
|
301
|
+
"draft": draft,
|
|
302
|
+
"tags": result.get("tags"),
|
|
303
|
+
}
|
|
304
|
+
if args.json_output:
|
|
305
|
+
_print_json(payload)
|
|
306
|
+
else:
|
|
307
|
+
print(f"Created draft {draft.get('id')}: {title}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _drafts_schedule(api, args):
|
|
311
|
+
scheduled_at = _parse_schedule(args.at)
|
|
312
|
+
result = api.schedule_draft(args.draft_id, scheduled_at)
|
|
313
|
+
payload = {
|
|
314
|
+
"action": "schedule",
|
|
315
|
+
"draft_id": args.draft_id,
|
|
316
|
+
"scheduled_at": scheduled_at.isoformat(),
|
|
317
|
+
"result": result,
|
|
318
|
+
}
|
|
319
|
+
if args.json_output:
|
|
320
|
+
_print_json(payload)
|
|
321
|
+
else:
|
|
322
|
+
print(f"Scheduled draft {args.draft_id} for {scheduled_at.isoformat()}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _drafts_unschedule(api, args):
|
|
326
|
+
result = api.unschedule_draft(args.draft_id)
|
|
327
|
+
payload = {"action": "unschedule", "draft_id": args.draft_id, "result": result}
|
|
328
|
+
if args.json_output:
|
|
329
|
+
_print_json(payload)
|
|
330
|
+
else:
|
|
331
|
+
print(f"Unscheduled draft {args.draft_id}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _drafts_publish(api, args):
|
|
335
|
+
_confirm("Publish", args.draft_id, args.yes, args.json_output)
|
|
336
|
+
prepublish = api.prepublish_draft(args.draft_id)
|
|
337
|
+
result = api.publish_draft(
|
|
338
|
+
args.draft_id,
|
|
339
|
+
send=args.send,
|
|
340
|
+
share_automatically=args.share_automatically,
|
|
341
|
+
)
|
|
342
|
+
payload = {
|
|
343
|
+
"action": "publish",
|
|
344
|
+
"draft_id": args.draft_id,
|
|
345
|
+
"prepublish": prepublish,
|
|
346
|
+
"result": result,
|
|
347
|
+
}
|
|
348
|
+
if args.json_output:
|
|
349
|
+
_print_json(payload)
|
|
350
|
+
else:
|
|
351
|
+
delivery = "without email" if not args.send else "with email"
|
|
352
|
+
print(f"Published draft {args.draft_id} {delivery}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _drafts_delete(api, args):
|
|
356
|
+
_confirm("Delete", args.draft_id, args.yes, args.json_output)
|
|
357
|
+
result = api.delete_draft(args.draft_id)
|
|
358
|
+
payload = {"action": "delete", "draft_id": args.draft_id, "result": result}
|
|
359
|
+
if args.json_output:
|
|
360
|
+
_print_json(payload)
|
|
361
|
+
else:
|
|
362
|
+
print(f"Deleted draft {args.draft_id}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _build_parser():
|
|
366
|
+
parser = argparse.ArgumentParser(
|
|
367
|
+
prog="substack", description="Manage Substack publications and drafts."
|
|
368
|
+
)
|
|
369
|
+
parser.add_argument("--cookies", help="Path to a cookies JSON file.")
|
|
370
|
+
parser.add_argument(
|
|
371
|
+
"--publication-url", help="Override PUBLICATION_URL for this command."
|
|
372
|
+
)
|
|
373
|
+
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
374
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
375
|
+
|
|
376
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
377
|
+
status = commands.add_parser(
|
|
378
|
+
"status", help="Show authentication and publication status."
|
|
379
|
+
)
|
|
380
|
+
status.set_defaults(handler=_status)
|
|
381
|
+
|
|
382
|
+
publications = commands.add_parser("publications", help="Manage publications.")
|
|
383
|
+
publication_commands = publications.add_subparsers(
|
|
384
|
+
dest="publication_command", required=True
|
|
385
|
+
)
|
|
386
|
+
publications_list = publication_commands.add_parser(
|
|
387
|
+
"list", help="List available publications."
|
|
388
|
+
)
|
|
389
|
+
publications_list.set_defaults(handler=_publications_list)
|
|
390
|
+
|
|
391
|
+
drafts = commands.add_parser("drafts", help="Manage drafts.")
|
|
392
|
+
draft_commands = drafts.add_subparsers(dest="draft_command", required=True)
|
|
393
|
+
|
|
394
|
+
drafts_list = draft_commands.add_parser("list", help="List drafts.")
|
|
395
|
+
drafts_list.add_argument("--filter", default="draft")
|
|
396
|
+
drafts_list.add_argument("--offset", type=int, default=0)
|
|
397
|
+
drafts_list.add_argument("--limit", type=int, default=25)
|
|
398
|
+
drafts_list.set_defaults(handler=_drafts_list)
|
|
399
|
+
|
|
400
|
+
drafts_get = draft_commands.add_parser("get", help="Inspect a draft.")
|
|
401
|
+
drafts_get.add_argument("draft_id", type=int)
|
|
402
|
+
drafts_get.set_defaults(handler=_drafts_get)
|
|
403
|
+
|
|
404
|
+
drafts_create = draft_commands.add_parser(
|
|
405
|
+
"create", help="Create a draft from a Markdown file."
|
|
406
|
+
)
|
|
407
|
+
drafts_create.add_argument("markdown_file", metavar="MARKDOWN_FILE")
|
|
408
|
+
drafts_create.add_argument("--title")
|
|
409
|
+
drafts_create.add_argument("--subtitle", default="")
|
|
410
|
+
drafts_create.add_argument("--audience", default="everyone")
|
|
411
|
+
drafts_create.add_argument("--write-comment-permissions", default="everyone")
|
|
412
|
+
drafts_create.add_argument("--search-engine-title")
|
|
413
|
+
drafts_create.add_argument("--search-engine-description")
|
|
414
|
+
drafts_create.add_argument("--slug")
|
|
415
|
+
drafts_create.add_argument("--draft-section-id", type=int)
|
|
416
|
+
drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
417
|
+
drafts_create.set_defaults(handler=_drafts_create)
|
|
418
|
+
|
|
419
|
+
drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
|
|
420
|
+
drafts_schedule.add_argument("draft_id", type=int)
|
|
421
|
+
drafts_schedule.add_argument("--at", required=True)
|
|
422
|
+
drafts_schedule.set_defaults(handler=_drafts_schedule)
|
|
423
|
+
|
|
424
|
+
drafts_unschedule = draft_commands.add_parser(
|
|
425
|
+
"unschedule", help="Remove a draft schedule."
|
|
426
|
+
)
|
|
427
|
+
drafts_unschedule.add_argument("draft_id", type=int)
|
|
428
|
+
drafts_unschedule.set_defaults(handler=_drafts_unschedule)
|
|
429
|
+
|
|
430
|
+
drafts_publish = draft_commands.add_parser("publish", help="Publish a draft.")
|
|
431
|
+
drafts_publish.add_argument("draft_id", type=int)
|
|
432
|
+
drafts_publish.add_argument(
|
|
433
|
+
"--no-send", action="store_false", dest="send", default=True
|
|
434
|
+
)
|
|
435
|
+
drafts_publish.add_argument("--share-automatically", action="store_true")
|
|
436
|
+
drafts_publish.add_argument("--yes", action="store_true")
|
|
437
|
+
drafts_publish.set_defaults(handler=_drafts_publish)
|
|
438
|
+
|
|
439
|
+
drafts_delete = draft_commands.add_parser("delete", help="Delete a draft.")
|
|
440
|
+
drafts_delete.add_argument("draft_id", type=int)
|
|
441
|
+
drafts_delete.add_argument("--yes", action="store_true")
|
|
442
|
+
drafts_delete.set_defaults(handler=_drafts_delete)
|
|
443
|
+
return parser
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def main(argv=None):
|
|
447
|
+
parser = _build_parser()
|
|
448
|
+
args = parser.parse_args(argv)
|
|
449
|
+
try:
|
|
450
|
+
api = _api_from_env(
|
|
451
|
+
cookies_path=args.cookies,
|
|
452
|
+
publication_url=args.publication_url,
|
|
453
|
+
)
|
|
454
|
+
args.handler(api, args)
|
|
455
|
+
except CLIUsageError as exc:
|
|
456
|
+
_print_usage_error(exc, args.json_output)
|
|
457
|
+
return 2
|
|
458
|
+
except (SubstackAPIException, SubstackRequestException, OSError, ValueError) as exc:
|
|
459
|
+
_print_error(exc, args.json_output)
|
|
460
|
+
return 1
|
|
461
|
+
return 0
|
|
462
|
+
|
|
463
|
+
|
|
59
464
|
def publish_markdown(argv=None):
|
|
60
465
|
parser = argparse.ArgumentParser()
|
|
61
466
|
parser.add_argument("markdown", nargs="?", default="README.md")
|
substack/mdrender.py
CHANGED
|
@@ -22,7 +22,11 @@ from typing import Dict, List, Optional
|
|
|
22
22
|
|
|
23
23
|
from markdown_it import MarkdownIt
|
|
24
24
|
from markdown_it.tree import SyntaxTreeNode
|
|
25
|
+
from mdit_py_plugins.container import container_plugin
|
|
26
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
25
27
|
from mdit_py_plugins.footnote import footnote_plugin
|
|
28
|
+
from mdit_py_plugins.subscript import sub_plugin
|
|
29
|
+
from mdit_py_plugins.superscript import superscript_plugin
|
|
26
30
|
|
|
27
31
|
from substack import nodes
|
|
28
32
|
from substack.nodes import MarkType, NodeType
|
|
@@ -31,11 +35,25 @@ _MARK_FOR = {
|
|
|
31
35
|
"strong": {"type": MarkType.STRONG},
|
|
32
36
|
"em": {"type": MarkType.EM},
|
|
33
37
|
"s": {"type": MarkType.STRIKETHROUGH},
|
|
38
|
+
"sup": {"type": MarkType.SUPERSCRIPT},
|
|
39
|
+
"sub": {"type": MarkType.SUBSCRIPT},
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
def _make_parser() -> MarkdownIt:
|
|
38
|
-
return
|
|
44
|
+
return (
|
|
45
|
+
MarkdownIt("commonmark")
|
|
46
|
+
.use(footnote_plugin)
|
|
47
|
+
# Pandoc-style delimiters: no whitespace just inside the dollars and no
|
|
48
|
+
# digit just outside them, so paired currency amounts ("$5 ... $10")
|
|
49
|
+
# stay plain text instead of becoming math.
|
|
50
|
+
.use(dollarmath_plugin, allow_space=False, allow_digits=False)
|
|
51
|
+
.use(sub_plugin)
|
|
52
|
+
.use(superscript_plugin)
|
|
53
|
+
.use(container_plugin, name="pullquote")
|
|
54
|
+
.use(container_plugin, name="callout")
|
|
55
|
+
.enable("strikethrough")
|
|
56
|
+
)
|
|
39
57
|
|
|
40
58
|
|
|
41
59
|
def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
|
|
@@ -64,6 +82,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
|
|
|
64
82
|
out.append(nodes.text(child.content, marks))
|
|
65
83
|
elif t == "code_inline":
|
|
66
84
|
out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
|
|
85
|
+
elif t == "math_inline":
|
|
86
|
+
out.append(nodes.latex_inline(child.content.strip()))
|
|
67
87
|
elif t in _MARK_FOR:
|
|
68
88
|
out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
|
|
69
89
|
elif t == "link":
|
|
@@ -159,10 +179,28 @@ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
|
159
179
|
if t == "ordered_list":
|
|
160
180
|
return [nodes.ordered_list(_render_list_items(node, api, ctx))]
|
|
161
181
|
|
|
182
|
+
# "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
|
|
183
|
+
# labels, so it renders like an unlabeled block.
|
|
184
|
+
if t in ("math_block", "math_block_label"):
|
|
185
|
+
return [nodes.latex_block(node.content.strip())]
|
|
186
|
+
|
|
187
|
+
if t == "container_pullquote":
|
|
188
|
+
return [nodes.pullquote(_render_container_body(node, api, ctx))]
|
|
189
|
+
|
|
190
|
+
if t == "container_callout":
|
|
191
|
+
return [nodes.callout_block(_render_container_body(node, api, ctx))]
|
|
192
|
+
|
|
162
193
|
# footnote_block is handled separately in markdown_to_doc; ignore it here.
|
|
163
194
|
return []
|
|
164
195
|
|
|
165
196
|
|
|
197
|
+
def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
198
|
+
body: List[Dict] = []
|
|
199
|
+
for child in node.children:
|
|
200
|
+
body.extend(_render_block(child, api, ctx))
|
|
201
|
+
return body
|
|
202
|
+
|
|
203
|
+
|
|
166
204
|
def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
167
205
|
items = []
|
|
168
206
|
for li in list_node.children:
|
substack/nodes.py
CHANGED
|
@@ -33,6 +33,10 @@ class NodeType:
|
|
|
33
33
|
FOOTNOTE_ANCHOR = "footnoteAnchor"
|
|
34
34
|
CAPTIONED_IMAGE = "captionedImage"
|
|
35
35
|
CAPTION = "caption"
|
|
36
|
+
LATEX_BLOCK = "latex_block"
|
|
37
|
+
LATEX_INLINE = "latex"
|
|
38
|
+
PULLQUOTE = "pullquote"
|
|
39
|
+
CALLOUT_BLOCK = "calloutBlock"
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
class MarkType:
|
|
@@ -40,6 +44,8 @@ class MarkType:
|
|
|
40
44
|
EM = "em"
|
|
41
45
|
CODE = "code"
|
|
42
46
|
STRIKETHROUGH = "strikethrough"
|
|
47
|
+
SUPERSCRIPT = "superscript"
|
|
48
|
+
SUBSCRIPT = "subscript"
|
|
43
49
|
LINK = "link"
|
|
44
50
|
|
|
45
51
|
|
|
@@ -146,3 +152,32 @@ def footnote(number: int, paragraphs: List[Dict]) -> Dict:
|
|
|
146
152
|
"attrs": {"number": number},
|
|
147
153
|
"content": paragraphs or [paragraph()],
|
|
148
154
|
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def latex_block(expression: str) -> Dict:
|
|
158
|
+
return {
|
|
159
|
+
"type": NodeType.LATEX_BLOCK,
|
|
160
|
+
"attrs": {"persistentExpression": expression, "dirty": True},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def latex_inline(expression: str) -> Dict:
|
|
165
|
+
return {
|
|
166
|
+
"type": NodeType.LATEX_INLINE,
|
|
167
|
+
"attrs": {"expression": expression, "persistentExpression": expression},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pullquote(paragraphs: List[Dict]) -> Dict:
|
|
172
|
+
return {
|
|
173
|
+
"type": NodeType.PULLQUOTE,
|
|
174
|
+
"attrs": {"align": None, "color": None},
|
|
175
|
+
"content": paragraphs or [paragraph()],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def callout_block(paragraphs: List[Dict]) -> Dict:
|
|
180
|
+
return {
|
|
181
|
+
"type": NodeType.CALLOUT_BLOCK,
|
|
182
|
+
"content": paragraphs or [paragraph()],
|
|
183
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=sm5s0r53o_oBjA_3yUob0L4VwXeT8bNuA20PnAjTflI,390
|
|
2
|
-
substack/api.py,sha256=QX9A_7PanQd_Q_QJwnxXM9auIHCtKd7rHbFTDATcm-U,22640
|
|
3
|
-
substack/cli.py,sha256=Hu9tBjyIW9zl7IE92VuZ-MOThS2cLpwXXAOR8Ixsu6o,7708
|
|
4
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
5
|
-
substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
|
|
6
|
-
substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
|
|
7
|
-
substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
|
|
8
|
-
substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
|
|
9
|
-
python_substack-0.1.25.dist-info/METADATA,sha256=Ero48_tB_HIHbkccKxfKK24rvOJiqlO80SlXItRgpC4,8720
|
|
10
|
-
python_substack-0.1.25.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
11
|
-
python_substack-0.1.25.dist-info/entry_points.txt,sha256=la9TUtzyaVksmdSkdKZsg55XIRXx4KjkKW7J3pvBegc,209
|
|
12
|
-
python_substack-0.1.25.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
-
python_substack-0.1.25.dist-info/RECORD,,
|
|
File without changes
|