Skip to content

Commit 77c4142

Browse files
committed
Release 0.1.27 Markdown CLI and reliability
1 parent 162c568 commit 77c4142

17 files changed

Lines changed: 627 additions & 26 deletions

‎.github/workflows/ci.yml‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,50 @@ jobs:
2727
python -m pip install poetry==2.3.2
2828
poetry install --all-extras
2929
- name: Run tests
30-
run: poetry run pytest -q
30+
run: poetry run pytest -q -m "not live" --strict-markers
3131

32-
build:
32+
package:
3333
needs: test
3434
runs-on: ubuntu-latest
35+
strategy:
36+
fail-fast: false
37+
matrix:
38+
python-version: ["3.10", "3.14"]
3539
steps:
3640
- uses: actions/checkout@v6
3741
- uses: actions/setup-python@v6
3842
with:
39-
python-version: "3.12"
43+
python-version: ${{ matrix.python-version }}
4044
cache: pip
4145
- name: Install build tools
4246
run: python -m pip install poetry==2.3.2 twine==6.2.0
47+
- name: Verify test collection
48+
run: |
49+
poetry install --all-extras
50+
poetry run pytest --collect-only -q --strict-markers
4351
- name: Validate and build package
4452
run: |
4553
poetry check
4654
poetry build
4755
python -m twine check dist/*
48-
- name: Smoke test wheel installations
56+
- name: Smoke test base wheel installation
4957
run: |
5058
WHEEL=$(find dist -name '*.whl' -print -quit)
5159
python -m venv /tmp/python-substack-base
5260
/tmp/python-substack-base/bin/pip install "$WHEEL"
61+
/tmp/python-substack-base/bin/pip check
5362
/tmp/python-substack-base/bin/substack --version
54-
/tmp/python-substack-base/bin/python -c "import substack"
63+
/tmp/python-substack-base/bin/python -c "import importlib.util; assert importlib.util.find_spec('fastmcp') is None"
64+
/tmp/python-substack-base/bin/python -c "from importlib import metadata; import substack; expected={'substack':'substack.cli:main','substack-auth-check':'substack.cli:auth_check','substack-publish-markdown':'substack.cli:publish_markdown','substack-publish-yaml':'substack.cli:publish_yaml','substack-mcp':'substack_mcp.mcp_server:main'}; actual={entry.name:entry.value for entry in metadata.entry_points(group='console_scripts') if entry.name in expected}; package=metadata.metadata('python-substack'); requirements=metadata.requires('python-substack'); assert actual == expected; assert metadata.version('python-substack') == substack.__version__; assert package['Requires-Python'] == '>=3.10,<4.0'; assert package.get_all('Provides-Extra') == ['mcp']; assert all(any(requirement.startswith(name) for requirement in requirements) for name in ['PyYAML','fastmcp','markdown-it-py','mdit-py-plugins','python-dotenv','requests'])"
65+
- name: Smoke test MCP wheel installation
66+
run: |
67+
WHEEL=$(find dist -name '*.whl' -print -quit)
5568
python -m venv /tmp/python-substack-mcp
5669
/tmp/python-substack-mcp/bin/pip install "${WHEEL}[mcp]"
70+
/tmp/python-substack-mcp/bin/pip check
5771
/tmp/python-substack-mcp/bin/python -c "import substack_mcp.mcp_server"
5872
- uses: actions/upload-artifact@v7
73+
if: matrix.python-version == '3.14'
5974
with:
6075
name: python-substack-distributions
6176
path: dist/

‎.github/workflows/ci_publish.yml‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,39 @@ jobs:
2020
cache: pip
2121
- name: Install release tools
2222
run: python -m pip install poetry==2.3.2 twine==6.2.0
23+
- name: Validate tag and package versions
24+
run: |
25+
VERSION=$(poetry version -s)
26+
PACKAGE_VERSION=$(python -c "import pathlib, re; print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('substack/__init__.py').read_text()).group(1))")
27+
test "$VERSION" = "$PACKAGE_VERSION"
28+
test "$GITHUB_REF_TYPE" = "tag"
29+
test "$GITHUB_REF_NAME" = "v$VERSION"
2330
- name: Test release source
2431
run: |
2532
poetry install --all-extras
26-
poetry run pytest -q
33+
poetry run pytest -q -m "not live" --strict-markers
34+
poetry run pytest --collect-only -q --strict-markers
2735
poetry check
2836
- name: Build and validate distributions
2937
run: |
3038
poetry build
3139
python -m twine check dist/*
40+
- name: Smoke test base wheel installation
41+
run: |
42+
WHEEL=$(find dist -name '*.whl' -print -quit)
43+
python -m venv /tmp/python-substack-base
44+
/tmp/python-substack-base/bin/pip install "$WHEEL"
45+
/tmp/python-substack-base/bin/pip check
46+
/tmp/python-substack-base/bin/substack --version
47+
/tmp/python-substack-base/bin/python -c "import importlib.util; assert importlib.util.find_spec('fastmcp') is None"
48+
/tmp/python-substack-base/bin/python -c "from importlib import metadata; import substack; expected={'substack':'substack.cli:main','substack-auth-check':'substack.cli:auth_check','substack-publish-markdown':'substack.cli:publish_markdown','substack-publish-yaml':'substack.cli:publish_yaml','substack-mcp':'substack_mcp.mcp_server:main'}; actual={entry.name:entry.value for entry in metadata.entry_points(group='console_scripts') if entry.name in expected}; package=metadata.metadata('python-substack'); requirements=metadata.requires('python-substack'); assert actual == expected; assert metadata.version('python-substack') == substack.__version__; assert package['Requires-Python'] == '>=3.10,<4.0'; assert package.get_all('Provides-Extra') == ['mcp']; assert all(any(requirement.startswith(name) for requirement in requirements) for name in ['PyYAML','fastmcp','markdown-it-py','mdit-py-plugins','python-dotenv','requests'])"
49+
- name: Smoke test MCP wheel installation
50+
run: |
51+
WHEEL=$(find dist -name '*.whl' -print -quit)
52+
python -m venv /tmp/python-substack-mcp
53+
/tmp/python-substack-mcp/bin/pip install "${WHEEL}[mcp]"
54+
/tmp/python-substack-mcp/bin/pip check
55+
/tmp/python-substack-mcp/bin/python -c "import substack_mcp.mcp_server"
3256
- name: Publish validated distributions
3357
uses: pypa/gh-action-pypi-publish@release/v1
3458
with:

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 0.1.27
4+
5+
### Added
6+
7+
- Unified `substack drafts create MARKDOWN_FILE` command for safe draft-only Markdown creation.
8+
- Markdown rendering for LaTeX, superscript, subscript, pull quotes, and callouts.
9+
- Registered `live` test marker with documented opt-in API and CLI smoke commands.
10+
11+
### Fixed
12+
13+
- Support Substack subscriber responses that provide a `subscribers` list instead of `subscriberCount`.
14+
- Use Substack's current scheduled-release endpoint for scheduling and unscheduling drafts.
15+
- Retry rate-limited GET and DELETE requests with bounded backoff without retrying write-producing POST requests.
16+
17+
### Improved
18+
19+
- Validate offline tests separately from live integrations.
20+
- Validate wheel metadata, console entry points, base installations, and MCP-extra installations on Python 3.10 and 3.14.
21+
- Require release tags, package metadata, and `substack.__version__` to agree before publishing.
22+
323
## 0.1.26
424

525
### Added

‎README.md‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,33 @@ Newer Substack accounts may only have magic-link sign-in enabled. To set a passw
5252

5353
## CLI Operations
5454

55+
Create a draft from Markdown without publishing it:
56+
57+
```bash
58+
substack drafts create post.md
59+
```
60+
61+
Set metadata and repeat `--tag` to attach multiple tags:
62+
63+
```bash
64+
substack --json drafts create post.md \
65+
--title "My Post" \
66+
--subtitle "Optional subtitle" \
67+
--tag python \
68+
--tag substack \
69+
--slug my-post \
70+
--search-engine-title "SEO title" \
71+
--search-engine-description "SEO description"
72+
```
73+
74+
Creation and publishing are intentionally separate. Use the returned draft ID
75+
when the draft is ready:
76+
77+
```bash
78+
substack drafts create post.md
79+
substack drafts publish 12345 --no-send
80+
```
81+
5582
Check authentication, the selected publication, and subscriber count:
5683

5784
```bash
@@ -128,7 +155,9 @@ print(result["draft"]["id"])
128155

129156
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
130157

131-
## Content Publishing CLI
158+
## Legacy Content Publishing CLI
159+
160+
The existing standalone commands remain supported for compatibility.
132161

133162
Check authentication without creating a draft:
134163

@@ -386,11 +415,28 @@ pre-commit install
386415
pytest
387416
```
388417

389-
Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
418+
Run the offline suite with:
419+
420+
```bash
421+
pytest -m "not live"
422+
```
390423

391-
The CLI operations smoke test is separately opt-in. Set `RUN_SUBSTACK_CLI_E2E=1` to create, schedule, unschedule, inspect, and delete a disposable draft. It never publishes the draft.
424+
Live Substack tests are not part of normal CI. They are opt-in and require
425+
configured credentials:
426+
427+
```bash
428+
RUN_SUBSTACK_E2E=1 pytest -m live
429+
```
430+
431+
The CLI operations smoke tests are separately opt-in. They create, inspect, and
432+
delete disposable drafts but never publish them:
433+
434+
```bash
435+
RUN_SUBSTACK_CLI_E2E=1 pytest -m live tests/substack/test_cli_end_to_end.py
436+
```
392437

393438
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
439+
The maintainer release process is documented in [docs/releasing.md](docs/releasing.md).
394440

395441
## Disclaimer
396442

‎docs/markdown.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ Delimiters follow Pandoc's rules: the opening `$` must not be followed by
139139
whitespace, and the closing `$` must not be preceded by whitespace or followed
140140
by a digit. Ordinary dollar amounts (`$5 million to $10 million`) therefore
141141
stay plain text. A label after a block (`$$ ... $$ (label)`) is accepted but
142-
discarded, since Substack has no equation labels.
142+
discarded, since Substack has no equation labels. Unclosed math delimiters remain
143+
plain text.
143144

144145
## Pull quotes and callouts
145146

@@ -156,6 +157,9 @@ A callout block, e.g. an aside or note.
156157
:::
157158
```
158159

160+
Empty pull quotes and callouts produce an empty paragraph so the resulting
161+
document remains valid. Unknown `:::` container names remain ordinary text.
162+
159163
## Not supported
160164

161165
- **Tables** — Substack has no table renderer or editor UI for them, so GFM

‎docs/releases/0.1.27.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# python-substack 0.1.27
2+
3+
Version 0.1.27 turns a Markdown file into a rich Substack draft through the
4+
main CLI and strengthens package validation around that workflow.
5+
6+
## Highlights
7+
8+
- Create an unpublished draft with `substack drafts create post.md`.
9+
- Set title, subtitle, audience, comment permissions, SEO metadata, slug,
10+
section, and repeatable tags from the unified CLI.
11+
- Render inline and block LaTeX, superscript, subscript, pull quotes, and
12+
callouts from Markdown.
13+
- Handle both known subscriber-count response formats.
14+
- Restore draft scheduling and unscheduling against Substack's current endpoint.
15+
- Retry rate-limited reads and deletes with bounded backoff while avoiding unsafe POST retries.
16+
- Keep live Substack tests opt-in while validating offline tests on Python 3.10
17+
through 3.14 and clean wheel installations on Python 3.10 and 3.14.
18+
19+
Existing Python APIs, MCP tools, environment variables, standalone console
20+
commands, defaults, and CLI output contracts remain available.
21+
22+
## Quick examples
23+
24+
```bash
25+
substack drafts create post.md
26+
substack --json drafts create post.md --tag python --tag substack
27+
substack drafts publish 12345 --no-send
28+
```
29+
30+
Creation never publishes, schedules, sends, or deletes a draft. Publishing
31+
remains a separate command with the existing confirmation safeguards. If tag
32+
assignment fails after Substack creates the draft, the draft may remain in the
33+
publication and is not deleted automatically.
34+
35+
## Observable Markdown changes
36+
37+
Text matching the new `$...$`, `$$...$$`, `^...^`, `~...~`, `:::pullquote`,
38+
or `:::callout` syntax now produces corresponding Substack document nodes.
39+
Ordinary currency amounts remain text, and `~~...~~` remains strikethrough.
40+
41+
## Announcement copy
42+
43+
python-substack 0.1.27 can now turn a Markdown file directly into a rich,
44+
unpublished Substack draft with `substack drafts create post.md`. The release
45+
adds LaTeX, superscript, subscript, pull quotes, and callouts, while strengthening
46+
clean-install and release validation. Install it with
47+
`pip install -U python-substack`.

‎docs/releasing.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Release checklist
2+
3+
1. Keep `pyproject.toml` and `substack/__init__.py` on the previous version
4+
while implementing code, tests, workflows, and documentation.
5+
2. Run `poetry install --all-extras`.
6+
3. Run `poetry run pytest -q -m "not live" --strict-markers`.
7+
4. Run `poetry run pytest -q` with live opt-ins unset and confirm all live tests
8+
are skipped.
9+
5. With configured maintainer credentials, run:
10+
11+
```bash
12+
RUN_SUBSTACK_E2E=1 poetry run pytest -q -m live
13+
RUN_SUBSTACK_CLI_E2E=1 poetry run pytest -q -m live tests/substack/test_cli_end_to_end.py
14+
```
15+
16+
6. Update `pyproject.toml`, `substack.__version__`, `CHANGELOG.md`, and the
17+
release page to the target version.
18+
7. From a clean checkout, run `poetry check`, `poetry build`, and
19+
`poetry run python -m twine check dist/*`.
20+
8. Install the wheel in fresh base and `[mcp]` virtual environments, run
21+
`pip check`, verify all five console entry points, and import the base and MCP
22+
modules.
23+
9. Push only after CI passes. Create `vX.Y.Z` from that exact commit; the publish
24+
workflow rejects a tag that differs from either package version.
25+
10. After publishing, install `python-substack==X.Y.Z` from PyPI in a clean
26+
environment and repeat the smoke checks before announcing.
27+
28+
Live tests create disposable drafts and must never publish. Confirm cleanup in
29+
the publication after each run. If an artifact is defective, yank it rather
30+
than attempting to replace the same version.

‎pyproject.toml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-substack"
3-
version = "0.1.26"
3+
version = "0.1.27"
44
description = "A Python SDK and CLI for managing Substack publications and drafts."
55
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
66
license = "MIT"
@@ -48,6 +48,11 @@ mcp = ["fastmcp"]
4848
[tool.poetry.group.dev.dependencies]
4949
pytest = "^9.1.1"
5050

51+
[tool.pytest.ini_options]
52+
markers = [
53+
"live: opt-in tests that call the live Substack service",
54+
]
55+
5156
[tool.poetry.scripts]
5257
substack = "substack.cli:main"
5358
substack-auth-check = "substack.cli:auth_check"

‎substack/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
__author__ = "Paolo Mazza"
44
__email__ = "mazzapaolo2019@gmail.com"
55
__license__ = "MIT License"
6-
__version__ = "0.1.26"
6+
__version__ = "0.1.27"
77
__url__ = "https://github.com/ma2za/python-substack"
88
__download_url__ = "https://pypi.python.org/pypi/python-substack"
99
__description__ = "A Python SDK and CLI for managing Substack publications and drafts"

‎substack/api.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from urllib.parse import unquote, urljoin
1313

1414
import requests
15+
from requests.adapters import HTTPAdapter, Retry
1516

1617
from substack.exceptions import SubstackAPIException, SubstackRequestException
1718

@@ -65,6 +66,18 @@ def __init__(
6566
logging.getLogger().setLevel(logging.DEBUG)
6667

6768
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)
6881

6982
# Load cookies from file if provided
7083
# Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
@@ -556,8 +569,8 @@ def schedule_draft(self, draft, draft_datetime: datetime) -> dict:
556569
557570
"""
558571
response = self._session.post(
559-
f"{self.publication_url}/drafts/{draft}/schedule",
560-
json={"post_date": draft_datetime.isoformat()},
572+
f"{self.publication_url}/drafts/{draft}/scheduled_release",
573+
json={"trigger_at": draft_datetime.isoformat()},
561574
)
562575
return Api._handle_response(response=response)
563576

@@ -570,8 +583,8 @@ def unschedule_draft(self, draft) -> dict:
570583
Returns:
571584
572585
"""
573-
response = self._session.post(
574-
f"{self.publication_url}/drafts/{draft}/schedule", json={"post_date": None}
586+
response = self._session.delete(
587+
f"{self.publication_url}/drafts/{draft}/scheduled_release"
575588
)
576589
return Api._handle_response(response=response)
577590

0 commit comments

Comments
 (0)