article 0.1.4__tar.gz → 0.1.6__tar.gz
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.
- {article-0.1.4 → article-0.1.6}/PKG-INFO +7 -2
- {article-0.1.4 → article-0.1.6}/README.md +3 -1
- {article-0.1.4 → article-0.1.6}/article/__init__.py +2 -0
- {article-0.1.4 → article-0.1.6}/article/__main__.py +45 -7
- {article-0.1.4 → article-0.1.6}/article/adapters/substack.py +267 -32
- {article-0.1.4 → article-0.1.6}/article/base.py +8 -0
- {article-0.1.4 → article-0.1.6}/article/config.py +8 -1
- {article-0.1.4 → article-0.1.6}/article/engine.py +37 -2
- {article-0.1.4 → article-0.1.6}/pyproject.toml +4 -1
- {article-0.1.4 → article-0.1.6}/tests/test_cli.py +2 -2
- {article-0.1.4 → article-0.1.6}/tests/test_pipeline.py +22 -0
- {article-0.1.4 → article-0.1.6}/tests/test_substack.py +96 -0
- {article-0.1.4 → article-0.1.6}/tests/test_substack_live.py +12 -1
- {article-0.1.4 → article-0.1.6}/.editorconfig +0 -0
- {article-0.1.4 → article-0.1.6}/.env.example +0 -0
- {article-0.1.4 → article-0.1.6}/.gitattributes +0 -0
- {article-0.1.4 → article-0.1.6}/.github/workflows/ci.yml +0 -0
- {article-0.1.4 → article-0.1.6}/.gitignore +0 -0
- {article-0.1.4 → article-0.1.6}/LICENSE +0 -0
- {article-0.1.4 → article-0.1.6}/article/adapters/__init__.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/adapters/dev_to.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/adapters/hashnode.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/adapters/medium.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/md_source.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/misc/CHANGELOG.md +0 -0
- {article-0.1.4 → article-0.1.6}/article/registry.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/state.py +0 -0
- {article-0.1.4 → article-0.1.6}/article/util.py +0 -0
- {article-0.1.4 → article-0.1.6}/examples/article.example.json +0 -0
- {article-0.1.4 → article-0.1.6}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: article
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Tools to semi-automate the publication of articles
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/article
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/article
|
|
@@ -26,6 +26,7 @@ Requires-Dist: pyyaml
|
|
|
26
26
|
Provides-Extra: completion
|
|
27
27
|
Requires-Dist: argcomplete; extra == 'completion'
|
|
28
28
|
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pillow; extra == 'dev'
|
|
29
30
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
31
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
32
|
Requires-Dist: python-substack<0.11,>=0.10; extra == 'dev'
|
|
@@ -33,6 +34,8 @@ Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
|
33
34
|
Provides-Extra: docs
|
|
34
35
|
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
35
36
|
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
37
|
+
Provides-Extra: images
|
|
38
|
+
Requires-Dist: pillow; extra == 'images'
|
|
36
39
|
Provides-Extra: substack
|
|
37
40
|
Requires-Dist: markdown-it-py>=3; extra == 'substack'
|
|
38
41
|
Requires-Dist: mdit-py-plugins>=0.4; extra == 'substack'
|
|
@@ -77,7 +80,7 @@ Image paths resolve against the Markdown file's directory. Before anything is up
|
|
|
77
80
|
|
|
78
81
|
| Construct | Why | What to do |
|
|
79
82
|
|---|---|---|
|
|
80
|
-
| A table | Substack has no table node | `--tables code` keeps it as a monospace code block;
|
|
83
|
+
| A table | Substack has no table node | `--tables code` keeps it as a monospace code block; `--tables image` renders it to a PNG (needs `pip install 'article[images]'`); in Python, `tables=` also takes a function returning the image Markdown |
|
|
81
84
|
| An image in a paragraph with text | the converter keeps only the alt text | give the image its own paragraph; caption goes in the title slot |
|
|
82
85
|
| A missing local image | nothing to upload | fix the path |
|
|
83
86
|
| A raw HTML block | the converter drops it | rewrite it in Markdown |
|
|
@@ -88,6 +91,8 @@ Image paths resolve against the Markdown file's directory. Before anything is up
|
|
|
88
91
|
article draft-substack essay.md --dry-run # what survives, no credentials needed
|
|
89
92
|
article draft-substack essay.md # create the draft; prints its edit link
|
|
90
93
|
article draft-substack essay.md --tables code # keep tables as code blocks
|
|
94
|
+
article draft-substack essay.md --update # rewrite the draft the last run created (refuses if you edited it in Substack; --overwrite-edits to replace those edits)
|
|
95
|
+
article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
|
|
91
96
|
```
|
|
92
97
|
|
|
93
98
|
The draft's future public URL (`<publication>/p/<slug>`) is recorded as the canonical URL, so `syndicate-secondary` can point the other platforms at it.
|
|
@@ -36,7 +36,7 @@ Image paths resolve against the Markdown file's directory. Before anything is up
|
|
|
36
36
|
|
|
37
37
|
| Construct | Why | What to do |
|
|
38
38
|
|---|---|---|
|
|
39
|
-
| A table | Substack has no table node | `--tables code` keeps it as a monospace code block;
|
|
39
|
+
| A table | Substack has no table node | `--tables code` keeps it as a monospace code block; `--tables image` renders it to a PNG (needs `pip install 'article[images]'`); in Python, `tables=` also takes a function returning the image Markdown |
|
|
40
40
|
| An image in a paragraph with text | the converter keeps only the alt text | give the image its own paragraph; caption goes in the title slot |
|
|
41
41
|
| A missing local image | nothing to upload | fix the path |
|
|
42
42
|
| A raw HTML block | the converter drops it | rewrite it in Markdown |
|
|
@@ -47,6 +47,8 @@ Image paths resolve against the Markdown file's directory. Before anything is up
|
|
|
47
47
|
article draft-substack essay.md --dry-run # what survives, no credentials needed
|
|
48
48
|
article draft-substack essay.md # create the draft; prints its edit link
|
|
49
49
|
article draft-substack essay.md --tables code # keep tables as code blocks
|
|
50
|
+
article draft-substack essay.md --update # rewrite the draft the last run created (refuses if you edited it in Substack; --overwrite-edits to replace those edits)
|
|
51
|
+
article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
|
|
50
52
|
```
|
|
51
53
|
|
|
52
54
|
The draft's future public URL (`<publication>/p/<slug>`) is recorded as the canonical URL, so `syndicate-secondary` can point the other platforms at it.
|
|
@@ -45,6 +45,7 @@ from .base import (
|
|
|
45
45
|
ArticleError,
|
|
46
46
|
ArticleValidationError,
|
|
47
47
|
CanonicalUrlMissing,
|
|
48
|
+
CanonicalUrlNotLive,
|
|
48
49
|
ConfigError,
|
|
49
50
|
Phase,
|
|
50
51
|
PlatformName,
|
|
@@ -122,6 +123,7 @@ __all__ = [
|
|
|
122
123
|
"AdapterError",
|
|
123
124
|
"UnknownPlatformError",
|
|
124
125
|
"CanonicalUrlMissing",
|
|
126
|
+
"CanonicalUrlNotLive",
|
|
125
127
|
# state
|
|
126
128
|
"JsonStateStore",
|
|
127
129
|
"default_state_store",
|
|
@@ -70,9 +70,14 @@ def syndicate_secondary(
|
|
|
70
70
|
env_file: Optional[str] = None,
|
|
71
71
|
state_path: Optional[str] = None,
|
|
72
72
|
platforms: Optional[str] = None,
|
|
73
|
+
allow_unpublished: bool = False,
|
|
73
74
|
json_out: bool = False,
|
|
74
75
|
):
|
|
75
|
-
"""Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO.
|
|
76
|
+
"""Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO.
|
|
77
|
+
|
|
78
|
+
Refused while the Substack post is still a draft (its URL would be a 404), unless
|
|
79
|
+
``allow_unpublished``.
|
|
80
|
+
"""
|
|
76
81
|
settings, article, store = _prepare(
|
|
77
82
|
article_path, env_file=env_file, state_path=state_path
|
|
78
83
|
)
|
|
@@ -80,7 +85,11 @@ def syndicate_secondary(
|
|
|
80
85
|
try:
|
|
81
86
|
summary = run_sync(
|
|
82
87
|
_syndicate_secondary(
|
|
83
|
-
article,
|
|
88
|
+
article,
|
|
89
|
+
settings=settings,
|
|
90
|
+
store=store,
|
|
91
|
+
platforms=selected,
|
|
92
|
+
allow_unpublished=allow_unpublished,
|
|
84
93
|
)
|
|
85
94
|
)
|
|
86
95
|
except ArticleError as e:
|
|
@@ -105,22 +114,41 @@ def draft_substack(
|
|
|
105
114
|
state_path: Optional[str] = None,
|
|
106
115
|
tables: Optional[str] = None,
|
|
107
116
|
dry_run: bool = False,
|
|
117
|
+
update: bool = False,
|
|
118
|
+
overwrite_edits: bool = False,
|
|
108
119
|
json_out: bool = False,
|
|
109
120
|
):
|
|
110
|
-
"""Create an UNPUBLISHED Substack draft from a Markdown file; never publishes.
|
|
121
|
+
"""Create an UNPUBLISHED Substack draft from a Markdown file; never publishes.
|
|
122
|
+
|
|
123
|
+
With ``update``, rewrite the draft this article's earlier run created (from the
|
|
124
|
+
state file) instead of creating another one.
|
|
125
|
+
"""
|
|
111
126
|
settings, article, store = _prepare(
|
|
112
127
|
article_path, env_file=env_file, state_path=state_path
|
|
113
128
|
)
|
|
114
129
|
config = article.platforms.substack or SubstackConfig()
|
|
115
130
|
if tables is not None:
|
|
116
131
|
config = config.model_copy(update={"tables": tables})
|
|
132
|
+
earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
|
|
133
|
+
if update:
|
|
134
|
+
if not earlier.get("draft_id"):
|
|
135
|
+
raise cw.CommandError(
|
|
136
|
+
f"--update: no earlier draft is recorded for {article.slug!r} in the "
|
|
137
|
+
f"state file; run once without --update first"
|
|
138
|
+
)
|
|
139
|
+
config = config.model_copy(
|
|
140
|
+
update={
|
|
141
|
+
"draft_id": earlier["draft_id"],
|
|
142
|
+
"expected_revision": earlier.get("revision"),
|
|
143
|
+
"overwrite_edits": overwrite_edits,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
117
146
|
try:
|
|
118
147
|
if dry_run:
|
|
119
148
|
report = _substack.dry_run(article, config=config)
|
|
120
149
|
return json.dumps(report, indent=2) if json_out else _render_dry_run(report)
|
|
121
150
|
platforms = article.platforms.model_copy(update={SUBSTACK: config})
|
|
122
151
|
article = article.model_copy(update={"platforms": platforms})
|
|
123
|
-
earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
|
|
124
152
|
summary = run_sync(_publish_primary(article, settings=settings, store=store))
|
|
125
153
|
except ArticleError as e:
|
|
126
154
|
raise cw.CommandError(str(e)) from e
|
|
@@ -131,7 +159,7 @@ def draft_substack(
|
|
|
131
159
|
detail = summary.by_platform[SUBSTACK].detail
|
|
132
160
|
lines = [summary.render(), f"Edit the draft: {detail.get('edit_url')}"]
|
|
133
161
|
lines += [f" warning: {w}" for w in detail.get("warnings", ())]
|
|
134
|
-
if earlier.get("edit_url"):
|
|
162
|
+
if earlier.get("edit_url") and not update:
|
|
135
163
|
lines.append(
|
|
136
164
|
f"Note: an earlier draft of this article is still on Substack "
|
|
137
165
|
f"({earlier['edit_url']}); delete it if this one replaces it."
|
|
@@ -149,11 +177,21 @@ _dispatch_config = {
|
|
|
149
177
|
command: {"article_path": {"help": "Path to the article JSON file"}}
|
|
150
178
|
for command in ("publish-primary", "syndicate-secondary")
|
|
151
179
|
}
|
|
180
|
+
_dispatch_config["syndicate-secondary"]["allow_unpublished"] = {
|
|
181
|
+
"help": "Syndicate even though the Substack post is still a draft (its URL is a 404)"
|
|
182
|
+
}
|
|
152
183
|
_dispatch_config["draft-substack"] = {
|
|
153
184
|
"article_path": {"help": "Path to the essay Markdown file (or article JSON)"},
|
|
154
185
|
"tables": {
|
|
155
|
-
"choices": ["error", "code"],
|
|
156
|
-
"help": "Markdown tables: refuse (error, the default)
|
|
186
|
+
"choices": ["error", "code", "image"],
|
|
187
|
+
"help": "Markdown tables: refuse (error, the default), keep as code blocks, "
|
|
188
|
+
"or render to an image (needs article[images])",
|
|
189
|
+
},
|
|
190
|
+
"update": {
|
|
191
|
+
"help": "Rewrite the draft an earlier run created instead of making a new one"
|
|
192
|
+
},
|
|
193
|
+
"overwrite_edits": {
|
|
194
|
+
"help": "With --update: replace edits made in the Substack editor since the last run"
|
|
157
195
|
},
|
|
158
196
|
"dry_run": {"help": "Check and convert only; no credentials, nothing sent"},
|
|
159
197
|
}
|
|
@@ -14,19 +14,30 @@ that publishes. Review the draft in the Substack editor and publish it there.
|
|
|
14
14
|
Before anything is uploaded, :func:`preflight` checks the Markdown for what
|
|
15
15
|
the converter would lose silently, and refuses it with every problem listed:
|
|
16
16
|
|
|
17
|
-
- **tables** (Substack has no table node): refused by default,
|
|
18
|
-
monospace code block with ``tables="code"``,
|
|
19
|
-
|
|
17
|
+
- **tables** (Substack has no table node): refused by default, kept as a
|
|
18
|
+
monospace code block with ``tables="code"``, rendered to a PNG with
|
|
19
|
+
``tables="image"`` (needs Pillow: ``pip install 'article[images]'``), or replaced
|
|
20
|
+
by whatever a callable returns, never dropped;
|
|
20
21
|
- an **image sharing a paragraph with text** (the converter keeps only its
|
|
21
22
|
alt text): put the image in its own paragraph, caption in the title slot;
|
|
22
23
|
- a **local image that does not exist** (paths resolve against the article's
|
|
23
|
-
``assets_dir``, the source file's directory)
|
|
24
|
+
``assets_dir``, the source file's directory) or that sits **inside a list, quote
|
|
25
|
+
or footnote** (Substack's editor is not known to accept those; move it to the top
|
|
26
|
+
level). Local image paths are rewritten to absolute ones, so conversion never
|
|
27
|
+
depends on, or changes, the process's working directory;
|
|
24
28
|
- a **raw HTML block** (the converter drops it).
|
|
25
29
|
|
|
26
30
|
The result's ``url`` is the post's public address once published
|
|
27
31
|
(``<publication>/p/<slug>``), which the engine records as the canonical URL;
|
|
28
32
|
``detail["edit_url"]`` opens the draft in the editor.
|
|
29
33
|
|
|
34
|
+
**Updating in place.** With ``config.draft_id`` set (the CLI's ``--update`` fills it
|
|
35
|
+
from the state store), the existing draft is rewritten with python-substack's
|
|
36
|
+
``update_draft_from_markdown`` instead of creating a new one. The draft's revision
|
|
37
|
+
(recorded when it was created or last updated) guards against clobbering edits made
|
|
38
|
+
in Substack's editor: a changed draft is refused unless ``config.overwrite_edits``.
|
|
39
|
+
The title is not updated in place.
|
|
40
|
+
|
|
30
41
|
The client is a seam: pass ``api=`` (anything with python-substack's ``Api``
|
|
31
42
|
methods) to :func:`create_substack_draft`; tests pass a fake.
|
|
32
43
|
"""
|
|
@@ -34,14 +45,15 @@ methods) to :func:`create_substack_draft`; tests pass a fake.
|
|
|
34
45
|
from __future__ import annotations
|
|
35
46
|
|
|
36
47
|
import asyncio
|
|
37
|
-
import
|
|
48
|
+
import hashlib
|
|
49
|
+
import json
|
|
38
50
|
import re
|
|
39
|
-
import
|
|
51
|
+
import struct
|
|
52
|
+
import tempfile
|
|
40
53
|
from collections import Counter
|
|
41
|
-
from contextlib import contextmanager
|
|
42
54
|
from pathlib import Path
|
|
43
55
|
from typing import Any, Callable, Iterator, Literal, Mapping, Optional, Union
|
|
44
|
-
from urllib.parse import unquote, urlsplit
|
|
56
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
45
57
|
|
|
46
58
|
from ..base import SUBSTACK, AdapterError, PublishResult
|
|
47
59
|
from ..config import Article, SubstackConfig
|
|
@@ -52,7 +64,7 @@ _log = get_logger()
|
|
|
52
64
|
|
|
53
65
|
#: How a Markdown table is handled: refuse it, keep it as a code block, or
|
|
54
66
|
#: replace it with the Markdown a callable returns (e.g. an image reference).
|
|
55
|
-
TablePolicy = Union[Literal["error", "code"], Callable[[str], str]]
|
|
67
|
+
TablePolicy = Union[Literal["error", "code", "image"], Callable[[str], str]]
|
|
56
68
|
|
|
57
69
|
_INSTALL_HINT = "install the Substack extra: pip install 'article[substack]'"
|
|
58
70
|
_AUTH_HINT = (
|
|
@@ -94,6 +106,61 @@ def _code_table(table_markdown: str) -> str:
|
|
|
94
106
|
return f"```\n{table_markdown.rstrip()}\n```\n"
|
|
95
107
|
|
|
96
108
|
|
|
109
|
+
def _split_row(line: str) -> list[str]:
|
|
110
|
+
cells = re.split(r"(?<!\\)\|", line.strip().strip("|"))
|
|
111
|
+
return [c.strip().replace("\\|", "|") for c in cells]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_table_image(
|
|
115
|
+
table_markdown: str, *, out_dir: Optional[str] = None, scale: int = 2
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Render a Markdown table to a PNG and return the Markdown image that shows it.
|
|
118
|
+
|
|
119
|
+
The file name is the content hash, so re-running reuses the file. Needs Pillow.
|
|
120
|
+
``out_dir`` defaults to a folder under the system temp directory.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
124
|
+
except ImportError as e:
|
|
125
|
+
raise AdapterError(
|
|
126
|
+
"substack: tables='image' needs Pillow: pip install 'article[images]'"
|
|
127
|
+
) from e
|
|
128
|
+
rows = [
|
|
129
|
+
_split_row(line)
|
|
130
|
+
for line in table_markdown.strip().splitlines()
|
|
131
|
+
if not re.fullmatch(r"\s*\|?[\s:|-]+\|?\s*", line) or "-" not in line
|
|
132
|
+
]
|
|
133
|
+
width = max(len(r) for r in rows)
|
|
134
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
135
|
+
font = ImageFont.load_default(size=14 * scale)
|
|
136
|
+
pad = 6 * scale
|
|
137
|
+
probe = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
|
138
|
+
col_w = [
|
|
139
|
+
max(probe.textlength(row[c], font=font) for row in rows) + 2 * pad
|
|
140
|
+
for c in range(width)
|
|
141
|
+
]
|
|
142
|
+
row_h = int(14 * scale * 1.4) + 2 * pad
|
|
143
|
+
image = Image.new("RGB", (int(sum(col_w)) + 1, row_h * len(rows) + 1), "white")
|
|
144
|
+
draw = ImageDraw.Draw(image)
|
|
145
|
+
for r, row in enumerate(rows):
|
|
146
|
+
x = 0.0
|
|
147
|
+
for c, cell in enumerate(row):
|
|
148
|
+
box = (x, r * row_h, x + col_w[c], (r + 1) * row_h)
|
|
149
|
+
if r == 0:
|
|
150
|
+
draw.rectangle(box, fill="#eeeeee")
|
|
151
|
+
draw.rectangle(box, outline="#999999")
|
|
152
|
+
draw.text((x + pad, r * row_h + pad), cell, fill="black", font=font)
|
|
153
|
+
x += col_w[c]
|
|
154
|
+
folder = (
|
|
155
|
+
Path(out_dir) if out_dir else Path(tempfile.gettempdir()) / "article-tables"
|
|
156
|
+
)
|
|
157
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
digest = hashlib.sha256(table_markdown.encode("utf-8")).hexdigest()[:16]
|
|
159
|
+
path = folder / f"table-{digest}.png"
|
|
160
|
+
image.save(path)
|
|
161
|
+
return f")})\n"
|
|
162
|
+
|
|
163
|
+
|
|
97
164
|
def _replace_tables(
|
|
98
165
|
markdown: str, tokens: list, policy: TablePolicy, problems: list[str]
|
|
99
166
|
) -> str:
|
|
@@ -103,10 +170,11 @@ def _replace_tables(
|
|
|
103
170
|
for t in tables:
|
|
104
171
|
problems.append(
|
|
105
172
|
f"{_where(t, lines)}: a table (Substack has no table; pass "
|
|
106
|
-
f"tables='code' to keep it as a code block, or
|
|
173
|
+
f"tables='code' to keep it as a code block, or tables='image' to "
|
|
174
|
+
f"render it to a picture)"
|
|
107
175
|
)
|
|
108
176
|
return markdown
|
|
109
|
-
render = _code_table
|
|
177
|
+
render = {"code": _code_table, "image": render_table_image}.get(policy, policy)
|
|
110
178
|
if not callable(render):
|
|
111
179
|
raise AdapterError(f"substack: unknown tables policy {policy!r}")
|
|
112
180
|
for t in reversed(tables): # bottom-up, so earlier line numbers stay valid
|
|
@@ -139,9 +207,20 @@ def _image_problems(
|
|
|
139
207
|
f"text, and Substack would keep only its alt text; give the image "
|
|
140
208
|
f'its own paragraph and put the caption in the title: '
|
|
141
209
|
)
|
|
210
|
+
nested = (
|
|
211
|
+
in_paragraph
|
|
212
|
+
and _is_image_only(tok.children)
|
|
213
|
+
and (tokens[i - 1].level > 0 or not tok.map)
|
|
214
|
+
)
|
|
142
215
|
for img in images:
|
|
143
216
|
src = img.attrs.get("src", "")
|
|
144
|
-
if src.lower().startswith("data:"):
|
|
217
|
+
if nested and _is_local(src) and not src.lower().startswith("data:"):
|
|
218
|
+
yield (
|
|
219
|
+
f"{line}: a local image inside a list, quote or footnote "
|
|
220
|
+
f"({src}); Substack's editor is not known to accept those, so "
|
|
221
|
+
f"move it to the top level"
|
|
222
|
+
)
|
|
223
|
+
elif src.lower().startswith("data:"):
|
|
145
224
|
yield f"{line}: an inline data: image cannot be uploaded; save it as a file"
|
|
146
225
|
elif (
|
|
147
226
|
_is_local(src)
|
|
@@ -150,6 +229,38 @@ def _image_problems(
|
|
|
150
229
|
yield f"{line}: image file not found: {src} (looked in {base})"
|
|
151
230
|
|
|
152
231
|
|
|
232
|
+
def _absolute_images(markdown: str, tokens: list, assets_dir: Optional[str]) -> str:
|
|
233
|
+
"""Rewrite each top-level image-only paragraph's local source to an absolute path.
|
|
234
|
+
|
|
235
|
+
python-substack resolves local image paths against the process's working
|
|
236
|
+
directory; absolute paths make conversion independent of it. Only images that
|
|
237
|
+
passed :func:`_image_problems` (so image-only top-level paragraphs) get here.
|
|
238
|
+
"""
|
|
239
|
+
base = Path(assets_dir) if assets_dir else Path.cwd()
|
|
240
|
+
lines = markdown.splitlines(keepends=True)
|
|
241
|
+
edits = []
|
|
242
|
+
for i, tok in enumerate(tokens):
|
|
243
|
+
if tok.type != "inline" or not tok.children or not tok.map:
|
|
244
|
+
continue
|
|
245
|
+
kids = [c for c in tok.children if c.type != "softbreak"]
|
|
246
|
+
image = next((c for c in kids if c.type == "image"), None)
|
|
247
|
+
if image is None or not _is_local(image.attrs.get("src", "")):
|
|
248
|
+
continue
|
|
249
|
+
if image.attrs["src"].lower().startswith("data:"):
|
|
250
|
+
continue
|
|
251
|
+
path = (base / Path(unquote(image.attrs["src"])).expanduser()).resolve()
|
|
252
|
+
alt = image.content.replace("]", "\\]")
|
|
253
|
+
title = image.attrs.get("title")
|
|
254
|
+
title_part = f' "{title.replace(chr(34), chr(92) + chr(34))}"' if title else ""
|
|
255
|
+
text = f")}>{title_part})"
|
|
256
|
+
if kids[0].type == "link_open":
|
|
257
|
+
text = f"[{text}]({kids[0].attrs.get('href', '')})"
|
|
258
|
+
edits.append((tok.map, text + "\n"))
|
|
259
|
+
for (start, end), text in reversed(edits): # bottom-up keeps line numbers valid
|
|
260
|
+
lines[start:end] = [text]
|
|
261
|
+
return "".join(lines)
|
|
262
|
+
|
|
263
|
+
|
|
153
264
|
def _is_image_only(children: list) -> bool:
|
|
154
265
|
kids = [c for c in children if c.type != "softbreak"]
|
|
155
266
|
if len(kids) == 1 and kids[0].type == "image":
|
|
@@ -251,6 +362,7 @@ def preflight(
|
|
|
251
362
|
f"substack: {len(problems)} problem(s) would lose content in the draft:\n"
|
|
252
363
|
+ "\n".join(f" - {p}" for p in problems)
|
|
253
364
|
)
|
|
365
|
+
markdown = _absolute_images(markdown, tokens, assets_dir)
|
|
254
366
|
return markdown, list(_html_inline_warnings(tokens, lines))
|
|
255
367
|
|
|
256
368
|
|
|
@@ -259,26 +371,67 @@ def preflight(
|
|
|
259
371
|
# --------------------------------------------------------------------------- #
|
|
260
372
|
|
|
261
373
|
|
|
262
|
-
|
|
374
|
+
def image_size(path: Union[str, Path]) -> Optional[tuple[int, int]]:
|
|
375
|
+
"""``(width, height)`` of a PNG, GIF or JPEG file read from its header, else ``None``.
|
|
263
376
|
|
|
377
|
+
Stdlib only; other formats are measured with Pillow when it is installed.
|
|
378
|
+
"""
|
|
379
|
+
with open(path, "rb") as f:
|
|
380
|
+
head = f.read(26)
|
|
381
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
382
|
+
return struct.unpack(">II", head[16:24])
|
|
383
|
+
if head[:6] in (b"GIF87a", b"GIF89a"):
|
|
384
|
+
return struct.unpack("<HH", head[6:10])
|
|
385
|
+
if head[:2] == b"\xff\xd8":
|
|
386
|
+
f.seek(2)
|
|
387
|
+
while True:
|
|
388
|
+
marker = f.read(4)
|
|
389
|
+
if len(marker) < 4 or marker[0] != 0xFF:
|
|
390
|
+
break
|
|
391
|
+
code, length = marker[1], struct.unpack(">H", marker[2:4])[0]
|
|
392
|
+
if 0xC0 <= code <= 0xCF and code not in (0xC4, 0xC8, 0xCC):
|
|
393
|
+
h, w = struct.unpack(">HH", f.read(5)[1:5])
|
|
394
|
+
return w, h
|
|
395
|
+
f.seek(length - 2, 1)
|
|
396
|
+
try:
|
|
397
|
+
from PIL import Image
|
|
398
|
+
|
|
399
|
+
with Image.open(path) as im:
|
|
400
|
+
return im.size
|
|
401
|
+
except Exception:
|
|
402
|
+
return None
|
|
264
403
|
|
|
265
|
-
@contextmanager
|
|
266
|
-
def _working_directory(path: Optional[str]) -> Iterator[None]:
|
|
267
|
-
"""python-substack resolves local image paths against the cwd.
|
|
268
404
|
|
|
269
|
-
|
|
270
|
-
|
|
405
|
+
class _MeasuringApi:
|
|
406
|
+
"""Wraps a python-substack ``Api``, remembering the pixel size of each upload.
|
|
407
|
+
|
|
408
|
+
python-substack gives every image node a default 1456x819; the sizes
|
|
409
|
+
recorded here are written back by :func:`_apply_image_sizes`.
|
|
271
410
|
"""
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
411
|
+
|
|
412
|
+
def __init__(self, api: Any):
|
|
413
|
+
self._api = api
|
|
414
|
+
self.sizes: dict[str, tuple[int, int]] = {}
|
|
415
|
+
|
|
416
|
+
def get_image(self, path):
|
|
417
|
+
uploaded = self._api.get_image(path)
|
|
418
|
+
size = image_size(path)
|
|
419
|
+
if size and isinstance(uploaded, Mapping) and uploaded.get("url"):
|
|
420
|
+
self.sizes[uploaded["url"]] = size
|
|
421
|
+
return uploaded
|
|
422
|
+
|
|
423
|
+
def __getattr__(self, name):
|
|
424
|
+
return getattr(self._api, name)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _apply_image_sizes(node: Any, sizes: Mapping[str, tuple[int, int]]) -> None:
|
|
428
|
+
if isinstance(node, dict):
|
|
429
|
+
attrs = node.get("attrs")
|
|
430
|
+
if node.get("type") == "image2" and isinstance(attrs, dict):
|
|
431
|
+
if attrs.get("src") in sizes:
|
|
432
|
+
attrs["width"], attrs["height"] = sizes[attrs["src"]]
|
|
433
|
+
for child in node.get("content") or []:
|
|
434
|
+
_apply_image_sizes(child, sizes)
|
|
282
435
|
|
|
283
436
|
|
|
284
437
|
def draft_document(
|
|
@@ -291,7 +444,8 @@ def draft_document(
|
|
|
291
444
|
) -> dict:
|
|
292
445
|
"""The draft payload python-substack posts (``draft_body`` is a JSON string).
|
|
293
446
|
|
|
294
|
-
|
|
447
|
+
``markdown`` is what :func:`preflight` returned (its local image paths are
|
|
448
|
+
absolute). With ``api=None`` nothing is uploaded.
|
|
295
449
|
"""
|
|
296
450
|
try:
|
|
297
451
|
from substack.post import Post
|
|
@@ -303,9 +457,14 @@ def draft_document(
|
|
|
303
457
|
user_id=user_id,
|
|
304
458
|
audience=config.audience,
|
|
305
459
|
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
460
|
+
measuring = _MeasuringApi(api) if api is not None else None
|
|
461
|
+
post.from_markdown(markdown, api=measuring)
|
|
462
|
+
draft = post.get_draft()
|
|
463
|
+
if measuring and measuring.sizes:
|
|
464
|
+
body = json.loads(draft["draft_body"])
|
|
465
|
+
_apply_image_sizes(body, measuring.sizes)
|
|
466
|
+
draft["draft_body"] = json.dumps(body)
|
|
467
|
+
return draft
|
|
309
468
|
|
|
310
469
|
|
|
311
470
|
def make_substack_api(
|
|
@@ -423,6 +582,10 @@ def create_substack_draft(
|
|
|
423
582
|
api = make_substack_api(secrets, publication_url=configured_url)
|
|
424
583
|
publication = _publication_base(api, configured_url)
|
|
425
584
|
|
|
585
|
+
if config.draft_id is not None:
|
|
586
|
+
return _update_draft(
|
|
587
|
+
article, markdown, warnings, config=config, api=api, publication=publication
|
|
588
|
+
)
|
|
426
589
|
draft = draft_document(
|
|
427
590
|
article, markdown, config=config, api=api, user_id=api.get_user_id()
|
|
428
591
|
)
|
|
@@ -465,12 +628,84 @@ def create_substack_draft(
|
|
|
465
628
|
detail={
|
|
466
629
|
"draft_id": draft_id,
|
|
467
630
|
"edit_url": edit_url,
|
|
631
|
+
"revision": _revision_of(api, draft_id),
|
|
468
632
|
"counts": summarize_document(draft),
|
|
469
633
|
"warnings": warnings,
|
|
470
634
|
},
|
|
471
635
|
)
|
|
472
636
|
|
|
473
637
|
|
|
638
|
+
def _revision_of(api: Any, draft_id: Any) -> Optional[dict]:
|
|
639
|
+
"""The draft's current revision (python-substack's conflict-check fingerprint), or None."""
|
|
640
|
+
try:
|
|
641
|
+
from substack.revision import compute_draft_revision
|
|
642
|
+
|
|
643
|
+
return compute_draft_revision(api.get_draft(draft_id))
|
|
644
|
+
except (
|
|
645
|
+
Exception
|
|
646
|
+
): # an unreadable revision only makes the next update ask to overwrite
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _update_draft(
|
|
651
|
+
article: Article,
|
|
652
|
+
markdown: str,
|
|
653
|
+
warnings: list[str],
|
|
654
|
+
*,
|
|
655
|
+
config: SubstackConfig,
|
|
656
|
+
api: Any,
|
|
657
|
+
publication: str,
|
|
658
|
+
) -> PublishResult:
|
|
659
|
+
"""Rewrite the existing draft ``config.draft_id`` from ``markdown``, never publishing."""
|
|
660
|
+
draft_id = config.draft_id
|
|
661
|
+
edit_url = f"{publication}/publish/post/{draft_id}"
|
|
662
|
+
tags = coalesce(config.tags, article.tags)
|
|
663
|
+
try:
|
|
664
|
+
result = api.update_draft_from_markdown(
|
|
665
|
+
draft_id,
|
|
666
|
+
markdown,
|
|
667
|
+
subtitle=article.subtitle or "",
|
|
668
|
+
audience=config.audience,
|
|
669
|
+
search_engine_description=article.description,
|
|
670
|
+
slug=article.slug,
|
|
671
|
+
draft_section_id=config.section_id,
|
|
672
|
+
tags=list(tags) if tags else None,
|
|
673
|
+
expected_revision=config.expected_revision,
|
|
674
|
+
allow_image_replacement=True, # the Markdown is the source of truth
|
|
675
|
+
allow_conflict=config.overwrite_edits,
|
|
676
|
+
)
|
|
677
|
+
except Exception as e:
|
|
678
|
+
hint = (
|
|
679
|
+
" (edited in Substack since the last run; pass overwrite_edits / "
|
|
680
|
+
"--overwrite-edits to replace those edits)"
|
|
681
|
+
if type(e).__name__ == "DraftConflictError"
|
|
682
|
+
else ""
|
|
683
|
+
)
|
|
684
|
+
raise AdapterError(
|
|
685
|
+
f"substack: updating draft {draft_id} ({edit_url}) failed: {e}{hint}"
|
|
686
|
+
) from e
|
|
687
|
+
slug = ((result or {}).get("draft") or {}).get("slug") or article.slug
|
|
688
|
+
if slug != article.slug:
|
|
689
|
+
warnings.append(f"Substack changed the slug from {article.slug!r} to {slug!r}")
|
|
690
|
+
warnings.append(
|
|
691
|
+
"the title is not updated in place; change it in the Substack editor"
|
|
692
|
+
)
|
|
693
|
+
public_url = f"{publication}/p/{slug}"
|
|
694
|
+
return PublishResult.success(
|
|
695
|
+
SUBSTACK,
|
|
696
|
+
url=public_url,
|
|
697
|
+
status="draft",
|
|
698
|
+
canonical_url=public_url,
|
|
699
|
+
detail={
|
|
700
|
+
"draft_id": draft_id,
|
|
701
|
+
"edit_url": edit_url,
|
|
702
|
+
"updated_in_place": True,
|
|
703
|
+
"revision": _revision_of(api, draft_id),
|
|
704
|
+
"warnings": warnings,
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
474
709
|
@register_adapter(SUBSTACK)
|
|
475
710
|
async def publish(
|
|
476
711
|
article: Article,
|
|
@@ -286,3 +286,11 @@ class UnknownPlatformError(ArticleError, KeyError):
|
|
|
286
286
|
|
|
287
287
|
class CanonicalUrlMissing(ArticleError):
|
|
288
288
|
"""Phase 2 was requested before phase 1 recorded a canonical_url."""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class CanonicalUrlNotLive(ArticleError):
|
|
292
|
+
"""Phase 2 was requested while the primary post is still an unpublished draft.
|
|
293
|
+
|
|
294
|
+
The recorded canonical URL is the post's *future* address, so syndicating now
|
|
295
|
+
would point other platforms at a 404.
|
|
296
|
+
"""
|
|
@@ -73,7 +73,14 @@ class SubstackConfig(_PlatformConfig):
|
|
|
73
73
|
#: What to do with a Markdown table, which Substack cannot represent:
|
|
74
74
|
#: ``"error"`` (refuse, naming every table) or ``"code"`` (keep it as a
|
|
75
75
|
#: monospace code block). Never dropped silently.
|
|
76
|
-
tables: Literal["error", "code"] = "error"
|
|
76
|
+
tables: Literal["error", "code", "image"] = "error"
|
|
77
|
+
#: Update this existing draft in place instead of creating a new one (the CLI's
|
|
78
|
+
#: ``--update`` fills it, and ``expected_revision``, from the state store).
|
|
79
|
+
draft_id: Optional[int] = None
|
|
80
|
+
#: The draft's revision as last recorded; a draft edited since is refused.
|
|
81
|
+
expected_revision: Optional[dict] = None
|
|
82
|
+
#: Overwrite edits made in the Substack editor since ``expected_revision``.
|
|
83
|
+
overwrite_edits: bool = False
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
class MediumConfig(_PlatformConfig):
|
|
@@ -35,6 +35,7 @@ from .base import (
|
|
|
35
35
|
SECONDARY_PLATFORMS,
|
|
36
36
|
AdapterError,
|
|
37
37
|
CanonicalUrlMissing,
|
|
38
|
+
CanonicalUrlNotLive,
|
|
38
39
|
Phase,
|
|
39
40
|
PlatformName,
|
|
40
41
|
PublishResult,
|
|
@@ -47,6 +48,21 @@ from .util import get_logger
|
|
|
47
48
|
|
|
48
49
|
AdapterResolver = Callable[[str], PublishAdapter]
|
|
49
50
|
|
|
51
|
+
#: Seconds the draft-liveness GET may take before the URL counts as not live.
|
|
52
|
+
DFLT_LIVE_TIMEOUT = 10.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def url_is_live(url: str, *, timeout: float = DFLT_LIVE_TIMEOUT) -> bool:
|
|
56
|
+
"""Whether one GET of ``url`` answers 2xx (redirects followed); any failure is False."""
|
|
57
|
+
import urllib.request
|
|
58
|
+
|
|
59
|
+
request = urllib.request.Request(url, headers={"User-Agent": "article-pipeline"})
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
62
|
+
return 200 <= response.status < 300
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
|
|
50
66
|
|
|
51
67
|
@dataclass
|
|
52
68
|
class PipelineEngine:
|
|
@@ -60,6 +76,9 @@ class PipelineEngine:
|
|
|
60
76
|
settings: Settings
|
|
61
77
|
store: MutableMapping
|
|
62
78
|
resolve_adapter: AdapterResolver = field(default=get_adapter)
|
|
79
|
+
#: ``url -> bool``: is the canonical URL live? Asked in phase 2 only while the
|
|
80
|
+
#: primary post is still a draft. Defaults to one HTTP GET (:func:`url_is_live`).
|
|
81
|
+
is_live: Callable[[str], bool] = field(default=url_is_live)
|
|
63
82
|
|
|
64
83
|
def __post_init__(self) -> None:
|
|
65
84
|
self._log = get_logger()
|
|
@@ -118,11 +137,14 @@ class PipelineEngine:
|
|
|
118
137
|
article: Article,
|
|
119
138
|
*,
|
|
120
139
|
platforms: Optional[Iterable[PlatformName]] = None,
|
|
140
|
+
allow_unpublished: bool = False,
|
|
121
141
|
) -> RunSummary:
|
|
122
142
|
"""Syndicate to each configured secondary, injecting the canonical URL.
|
|
123
143
|
|
|
124
144
|
Raises :class:`~article.base.CanonicalUrlMissing` if phase 1 hasn't
|
|
125
|
-
recorded a canonical URL for this slug yet
|
|
145
|
+
recorded a canonical URL for this slug yet, and
|
|
146
|
+
:class:`~article.base.CanonicalUrlNotLive` if the primary post is still a
|
|
147
|
+
draft whose URL does not resolve (``allow_unpublished=True`` skips the check).
|
|
126
148
|
"""
|
|
127
149
|
canonical_url = self.store.get_canonical_url(article.slug)
|
|
128
150
|
if not canonical_url:
|
|
@@ -131,6 +153,16 @@ class PipelineEngine:
|
|
|
131
153
|
f"Run `publish-primary` first so the SSOT URL exists."
|
|
132
154
|
)
|
|
133
155
|
|
|
156
|
+
primary = self.store.get(article.slug, {}).get("primary", {})
|
|
157
|
+
if primary.get("status") == "draft" and not allow_unpublished:
|
|
158
|
+
if not self.is_live(canonical_url):
|
|
159
|
+
raise CanonicalUrlNotLive(
|
|
160
|
+
f"The primary post for {article.slug!r} is still a draft: "
|
|
161
|
+
f"{canonical_url} does not resolve yet, so syndicating now would "
|
|
162
|
+
f"point other platforms at a 404. Publish it from the Substack "
|
|
163
|
+
f"editor first (or pass allow_unpublished to override)."
|
|
164
|
+
)
|
|
165
|
+
|
|
134
166
|
targets = self._secondary_targets(article, platforms)
|
|
135
167
|
if not targets:
|
|
136
168
|
self._log.warning("No secondary platforms configured for %r.", article.slug)
|
|
@@ -258,7 +290,10 @@ async def syndicate_secondary(
|
|
|
258
290
|
settings: Optional[Settings] = None,
|
|
259
291
|
store: Optional[MutableMapping] = None,
|
|
260
292
|
platforms: Optional[Iterable[PlatformName]] = None,
|
|
293
|
+
allow_unpublished: bool = False,
|
|
261
294
|
) -> RunSummary:
|
|
262
295
|
"""Phase 2 facade: syndicate to secondaries with the canonical_url injected."""
|
|
263
296
|
engine = build_engine(settings=settings, store=store)
|
|
264
|
-
return await engine.syndicate_secondary(
|
|
297
|
+
return await engine.syndicate_secondary(
|
|
298
|
+
article, platforms=platforms, allow_unpublished=allow_unpublished
|
|
299
|
+
)
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "article"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.6"
|
|
10
10
|
description = "Tools to semi-automate the publication of articles"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -57,6 +57,8 @@ substack = [
|
|
|
57
57
|
"markdown-it-py>=3",
|
|
58
58
|
"mdit-py-plugins>=0.4",
|
|
59
59
|
]
|
|
60
|
+
# tables="image": render Markdown tables to PNGs.
|
|
61
|
+
images = ["pillow"]
|
|
60
62
|
# Shell tab-completion for the CLI.
|
|
61
63
|
completion = [
|
|
62
64
|
"argcomplete",
|
|
@@ -66,6 +68,7 @@ dev = [
|
|
|
66
68
|
"pytest-cov>=4.0",
|
|
67
69
|
"ruff>=0.1.0",
|
|
68
70
|
"python-substack>=0.10,<0.11",
|
|
71
|
+
"pillow",
|
|
69
72
|
]
|
|
70
73
|
docs = [
|
|
71
74
|
"sphinx>=6.0",
|
|
@@ -82,7 +82,7 @@ def test_draft_substack_usage_line(subparsers):
|
|
|
82
82
|
usage = " ".join(subparsers["draft-substack"].format_usage().split())
|
|
83
83
|
assert usage == (
|
|
84
84
|
"usage: article draft-substack [-h] [-e ENV_FILE] [-s STATE_PATH]"
|
|
85
|
-
" [-t {error,code}] [-d] [-j] article-path"
|
|
85
|
+
" [-t {error,code,image}] [-d] [-u] [-o] [-j] article-path"
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
|
|
@@ -97,7 +97,7 @@ def test_draft_substack_usage_line(subparsers):
|
|
|
97
97
|
(
|
|
98
98
|
"syndicate-secondary",
|
|
99
99
|
"usage: article syndicate-secondary [-h] [-e ENV_FILE] [-s STATE_PATH]"
|
|
100
|
-
" [-p PLATFORMS] [-j] article-path",
|
|
100
|
+
" [-p PLATFORMS] [-a] [-j] article-path",
|
|
101
101
|
),
|
|
102
102
|
],
|
|
103
103
|
)
|
|
@@ -93,6 +93,7 @@ def _engine(tmp_path, *, resolve_adapter=get_adapter) -> PipelineEngine:
|
|
|
93
93
|
settings=settings,
|
|
94
94
|
store=store,
|
|
95
95
|
resolve_adapter=_with_fake_substack(resolve_adapter),
|
|
96
|
+
is_live=lambda url: True, # no network: the draft counts as published
|
|
96
97
|
)
|
|
97
98
|
|
|
98
99
|
|
|
@@ -256,3 +257,24 @@ def test_unconfigured_secondary_is_not_attempted(tmp_path):
|
|
|
256
257
|
asyncio.run(engine.publish_primary(art))
|
|
257
258
|
summary = asyncio.run(engine.syndicate_secondary(art))
|
|
258
259
|
assert {r.platform for r in summary.results} == {MEDIUM}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_phase2_refuses_while_the_substack_post_is_still_a_draft(tmp_path):
|
|
263
|
+
from article.base import CanonicalUrlNotLive
|
|
264
|
+
|
|
265
|
+
engine = _engine(tmp_path)
|
|
266
|
+
engine.is_live = lambda url: False
|
|
267
|
+
art = _article()
|
|
268
|
+
asyncio.run(engine.publish_primary(art))
|
|
269
|
+
with pytest.raises(CanonicalUrlNotLive, match="still a draft"):
|
|
270
|
+
asyncio.run(engine.syndicate_secondary(art))
|
|
271
|
+
summary = asyncio.run(engine.syndicate_secondary(art, allow_unpublished=True))
|
|
272
|
+
assert summary.canonical_url
|
|
273
|
+
engine.is_live = lambda url: True
|
|
274
|
+
assert asyncio.run(engine.syndicate_secondary(art)).canonical_url
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_url_is_live_is_false_for_an_unreachable_address():
|
|
278
|
+
from article.engine import url_is_live
|
|
279
|
+
|
|
280
|
+
assert url_is_live("http://127.0.0.1:9/never", timeout=0.2) is False
|
|
@@ -15,6 +15,8 @@ import os
|
|
|
15
15
|
import re
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import unquote
|
|
18
20
|
|
|
19
21
|
import pytest
|
|
20
22
|
|
|
@@ -362,3 +364,97 @@ def test_a_title_without_ascii_asks_for_a_slug(tmp_path):
|
|
|
362
364
|
(tmp_path / "x.md").write_text("# 日本語\n\nx\n", encoding="utf-8")
|
|
363
365
|
with pytest.raises(Exception, match="add a 'slug:'"):
|
|
364
366
|
load_article(str(tmp_path / "x.md"))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_uploaded_images_keep_their_real_pixel_size(tmp_path, fake_api):
|
|
370
|
+
import struct, zlib
|
|
371
|
+
|
|
372
|
+
def png(w, h):
|
|
373
|
+
def chunk(t, d):
|
|
374
|
+
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
|
|
375
|
+
|
|
376
|
+
raw = b"".join(b"\x00" + b"\x00\x00\x00" * w for _ in range(h))
|
|
377
|
+
return (
|
|
378
|
+
b"\x89PNG\r\n\x1a\n"
|
|
379
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
380
|
+
+ chunk(b"IDAT", zlib.compress(raw))
|
|
381
|
+
+ chunk(b"IEND", b"")
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
(tmp_path / "wide.png").write_bytes(png(30, 10))
|
|
385
|
+
(tmp_path / "tall.png").write_bytes(png(7, 21))
|
|
386
|
+
(tmp_path / "a.md").write_text("# T\n\n\n\n\n")
|
|
387
|
+
create_substack_draft(load_article(str(tmp_path / "a.md")), api=fake_api)
|
|
388
|
+
sizes = [
|
|
389
|
+
(n["attrs"]["width"], n["attrs"]["height"])
|
|
390
|
+
for n in _of_type(_body(fake_api), "image2")
|
|
391
|
+
]
|
|
392
|
+
assert sizes == [(30, 10), (7, 21)]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --------------------------------------------------------------------------- #1-#5 follow-ups
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_conversion_does_not_touch_the_working_directory(essay_dir, fake_api, monkeypatch):
|
|
399
|
+
"""Local image paths become absolute during preflight, so no process-wide chdir."""
|
|
400
|
+
seen = []
|
|
401
|
+
real_chdir = os.chdir
|
|
402
|
+
monkeypatch.setattr(os, "chdir", lambda p: (seen.append(p), real_chdir(p)))
|
|
403
|
+
monkeypatch.chdir(essay_dir.parent)
|
|
404
|
+
seen.clear()
|
|
405
|
+
art = load_article(str(essay_dir / "essay.md"))
|
|
406
|
+
create_substack_draft(art, api=fake_api)
|
|
407
|
+
assert seen == []
|
|
408
|
+
assert fake_api.uploaded == [str((essay_dir / "images" / "fig 1.png").resolve())]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_preflight_makes_local_image_paths_absolute(tmp_path, png_bytes):
|
|
412
|
+
(tmp_path / "a b.png").write_bytes(png_bytes)
|
|
413
|
+
md, _ = preflight('\n', assets_dir=str(tmp_path))
|
|
414
|
+
assert str(tmp_path.resolve()) in unquote(md) and 'a%20b.png' in md and '"cap"' in md
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_a_local_image_inside_a_list_is_refused(tmp_path, png_bytes):
|
|
418
|
+
(tmp_path / "a.png").write_bytes(png_bytes)
|
|
419
|
+
with pytest.raises(AdapterError, match="inside a list, quote or footnote"):
|
|
420
|
+
preflight("- \n", assets_dir=str(tmp_path))
|
|
421
|
+
# a remote one is fine
|
|
422
|
+
preflight("- \n", assets_dir=str(tmp_path))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_tables_image_policy_renders_a_png(tmp_path):
|
|
426
|
+
pytest.importorskip("PIL")
|
|
427
|
+
md, _ = preflight(
|
|
428
|
+
"| a | b |\n|---|---|\n| 1 | 2 |\n", tables="image", assets_dir=str(tmp_path)
|
|
429
|
+
)
|
|
430
|
+
assert md.startswith("
|
|
431
|
+
path = Path(unquote(md[len(" : md.index(")")]).strip("<>"))
|
|
432
|
+
assert path.is_file() and path.read_bytes()[:4] == b"\x89PNG"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_a_draft_is_updated_in_place_with_its_revision_guard(essay_dir, fake_api):
|
|
436
|
+
calls = []
|
|
437
|
+
fake_api.update_draft_from_markdown = lambda draft_id, md, **kw: (
|
|
438
|
+
calls.append((draft_id, md, kw)) or {"draft": {"slug": "the-agile-religion"}}
|
|
439
|
+
)
|
|
440
|
+
art = load_article(str(essay_dir / "essay.md"))
|
|
441
|
+
config = SubstackConfig(draft_id=1234, expected_revision={"revision": "abc"})
|
|
442
|
+
result = create_substack_draft(art, config=config, api=fake_api)
|
|
443
|
+
((draft_id, md, kwargs),) = calls
|
|
444
|
+
assert draft_id == 1234 and kwargs["expected_revision"] == {"revision": "abc"}
|
|
445
|
+
assert kwargs["allow_conflict"] is False and kwargs["slug"] == "the-agile-religion"
|
|
446
|
+
assert fake_api.posted == [] # no new draft
|
|
447
|
+
assert result.detail["updated_in_place"] and result.status == "draft"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_an_edited_draft_is_refused_with_a_hint(essay_dir, fake_api):
|
|
451
|
+
class DraftConflictError(ValueError):
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
def conflict(*args, **kwargs):
|
|
455
|
+
raise DraftConflictError("remote draft has changed")
|
|
456
|
+
|
|
457
|
+
fake_api.update_draft_from_markdown = conflict
|
|
458
|
+
art = load_article(str(essay_dir / "essay.md"))
|
|
459
|
+
with pytest.raises(AdapterError, match="overwrite_edits"):
|
|
460
|
+
create_substack_draft(art, config=SubstackConfig(draft_id=1), api=fake_api)
|
|
@@ -8,6 +8,7 @@ one draft and deletes it again; it never publishes.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import os
|
|
12
13
|
|
|
13
14
|
import pytest
|
|
@@ -29,6 +30,13 @@ pytestmark = pytest.mark.skipif(
|
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def _image_nodes(node):
|
|
34
|
+
found = [node] if node.get("type") == "image2" else []
|
|
35
|
+
for child in node.get("content") or []:
|
|
36
|
+
found += _image_nodes(child)
|
|
37
|
+
return found
|
|
38
|
+
|
|
39
|
+
|
|
32
40
|
def test_a_real_draft_keeps_footnotes_and_captions(tmp_path, png_bytes):
|
|
33
41
|
(tmp_path / "fig.png").write_bytes(png_bytes)
|
|
34
42
|
(tmp_path / "smoke.md").write_text(
|
|
@@ -47,6 +55,9 @@ def test_a_real_draft_keeps_footnotes_and_captions(tmp_path, png_bytes):
|
|
|
47
55
|
draft = api.get_draft(result.detail["draft_id"])
|
|
48
56
|
assert not draft.get("is_published")
|
|
49
57
|
body = str(draft.get("draft_body"))
|
|
50
|
-
assert "footnote" in body and "A caption" in body
|
|
58
|
+
assert "footnote" in body and "A caption" in body
|
|
59
|
+
(image,) = _image_nodes(json.loads(draft["draft_body"]))
|
|
60
|
+
assert image["attrs"]["src"].startswith("https://")
|
|
61
|
+
assert (image["attrs"]["width"], image["attrs"]["height"]) == (1, 1)
|
|
51
62
|
finally:
|
|
52
63
|
api.delete_draft(result.detail["draft_id"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|