article 0.1.4__tar.gz → 0.1.5__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.
Files changed (30) hide show
  1. {article-0.1.4 → article-0.1.5}/PKG-INFO +7 -2
  2. {article-0.1.4 → article-0.1.5}/README.md +3 -1
  3. {article-0.1.4 → article-0.1.5}/article/__init__.py +2 -0
  4. {article-0.1.4 → article-0.1.5}/article/__main__.py +45 -7
  5. {article-0.1.4 → article-0.1.5}/article/adapters/substack.py +201 -37
  6. {article-0.1.4 → article-0.1.5}/article/base.py +8 -0
  7. {article-0.1.4 → article-0.1.5}/article/config.py +8 -1
  8. {article-0.1.4 → article-0.1.5}/article/engine.py +37 -2
  9. {article-0.1.4 → article-0.1.5}/pyproject.toml +4 -1
  10. {article-0.1.4 → article-0.1.5}/tests/test_cli.py +2 -2
  11. {article-0.1.4 → article-0.1.5}/tests/test_pipeline.py +22 -0
  12. {article-0.1.4 → article-0.1.5}/tests/test_substack.py +70 -0
  13. {article-0.1.4 → article-0.1.5}/.editorconfig +0 -0
  14. {article-0.1.4 → article-0.1.5}/.env.example +0 -0
  15. {article-0.1.4 → article-0.1.5}/.gitattributes +0 -0
  16. {article-0.1.4 → article-0.1.5}/.github/workflows/ci.yml +0 -0
  17. {article-0.1.4 → article-0.1.5}/.gitignore +0 -0
  18. {article-0.1.4 → article-0.1.5}/LICENSE +0 -0
  19. {article-0.1.4 → article-0.1.5}/article/adapters/__init__.py +0 -0
  20. {article-0.1.4 → article-0.1.5}/article/adapters/dev_to.py +0 -0
  21. {article-0.1.4 → article-0.1.5}/article/adapters/hashnode.py +0 -0
  22. {article-0.1.4 → article-0.1.5}/article/adapters/medium.py +0 -0
  23. {article-0.1.4 → article-0.1.5}/article/md_source.py +0 -0
  24. {article-0.1.4 → article-0.1.5}/article/misc/CHANGELOG.md +0 -0
  25. {article-0.1.4 → article-0.1.5}/article/registry.py +0 -0
  26. {article-0.1.4 → article-0.1.5}/article/state.py +0 -0
  27. {article-0.1.4 → article-0.1.5}/article/util.py +0 -0
  28. {article-0.1.4 → article-0.1.5}/examples/article.example.json +0 -0
  29. {article-0.1.4 → article-0.1.5}/tests/conftest.py +0 -0
  30. {article-0.1.4 → article-0.1.5}/tests/test_substack_live.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: article
3
- Version: 0.1.4
3
+ Version: 0.1.5
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; or render it to an image (in Python, pass `tables=` a function returning the image Markdown) |
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; or render it to an image (in Python, pass `tables=` a function returning the image Markdown) |
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, settings=settings, store=store, platforms=selected
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) or keep as code blocks",
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, or kept as a
18
- monospace code block with ``tables="code"``, or replaced by whatever a
19
- callable returns (e.g. a rendered image), never dropped;
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,13 @@ methods) to :func:`create_substack_draft`; tests pass a fake.
34
45
  from __future__ import annotations
35
46
 
36
47
  import asyncio
37
- import os
48
+ import hashlib
38
49
  import re
39
- import threading
50
+ import tempfile
40
51
  from collections import Counter
41
- from contextlib import contextmanager
42
52
  from pathlib import Path
43
53
  from typing import Any, Callable, Iterator, Literal, Mapping, Optional, Union
44
- from urllib.parse import unquote, urlsplit
54
+ from urllib.parse import quote, unquote, urlsplit
45
55
 
46
56
  from ..base import SUBSTACK, AdapterError, PublishResult
47
57
  from ..config import Article, SubstackConfig
@@ -52,7 +62,7 @@ _log = get_logger()
52
62
 
53
63
  #: How a Markdown table is handled: refuse it, keep it as a code block, or
54
64
  #: replace it with the Markdown a callable returns (e.g. an image reference).
55
- TablePolicy = Union[Literal["error", "code"], Callable[[str], str]]
65
+ TablePolicy = Union[Literal["error", "code", "image"], Callable[[str], str]]
56
66
 
57
67
  _INSTALL_HINT = "install the Substack extra: pip install 'article[substack]'"
58
68
  _AUTH_HINT = (
@@ -94,6 +104,61 @@ def _code_table(table_markdown: str) -> str:
94
104
  return f"```\n{table_markdown.rstrip()}\n```\n"
95
105
 
96
106
 
107
+ def _split_row(line: str) -> list[str]:
108
+ cells = re.split(r"(?<!\\)\|", line.strip().strip("|"))
109
+ return [c.strip().replace("\\|", "|") for c in cells]
110
+
111
+
112
+ def render_table_image(
113
+ table_markdown: str, *, out_dir: Optional[str] = None, scale: int = 2
114
+ ) -> str:
115
+ """Render a Markdown table to a PNG and return the Markdown image that shows it.
116
+
117
+ The file name is the content hash, so re-running reuses the file. Needs Pillow.
118
+ ``out_dir`` defaults to a folder under the system temp directory.
119
+ """
120
+ try:
121
+ from PIL import Image, ImageDraw, ImageFont
122
+ except ImportError as e:
123
+ raise AdapterError(
124
+ "substack: tables='image' needs Pillow: pip install 'article[images]'"
125
+ ) from e
126
+ rows = [
127
+ _split_row(line)
128
+ for line in table_markdown.strip().splitlines()
129
+ if not re.fullmatch(r"\s*\|?[\s:|-]+\|?\s*", line) or "-" not in line
130
+ ]
131
+ width = max(len(r) for r in rows)
132
+ rows = [r + [""] * (width - len(r)) for r in rows]
133
+ font = ImageFont.load_default(size=14 * scale)
134
+ pad = 6 * scale
135
+ probe = ImageDraw.Draw(Image.new("RGB", (1, 1)))
136
+ col_w = [
137
+ max(probe.textlength(row[c], font=font) for row in rows) + 2 * pad
138
+ for c in range(width)
139
+ ]
140
+ row_h = int(14 * scale * 1.4) + 2 * pad
141
+ image = Image.new("RGB", (int(sum(col_w)) + 1, row_h * len(rows) + 1), "white")
142
+ draw = ImageDraw.Draw(image)
143
+ for r, row in enumerate(rows):
144
+ x = 0.0
145
+ for c, cell in enumerate(row):
146
+ box = (x, r * row_h, x + col_w[c], (r + 1) * row_h)
147
+ if r == 0:
148
+ draw.rectangle(box, fill="#eeeeee")
149
+ draw.rectangle(box, outline="#999999")
150
+ draw.text((x + pad, r * row_h + pad), cell, fill="black", font=font)
151
+ x += col_w[c]
152
+ folder = (
153
+ Path(out_dir) if out_dir else Path(tempfile.gettempdir()) / "article-tables"
154
+ )
155
+ folder.mkdir(parents=True, exist_ok=True)
156
+ digest = hashlib.sha256(table_markdown.encode("utf-8")).hexdigest()[:16]
157
+ path = folder / f"table-{digest}.png"
158
+ image.save(path)
159
+ return f"![table]({quote(str(path))})\n"
160
+
161
+
97
162
  def _replace_tables(
98
163
  markdown: str, tokens: list, policy: TablePolicy, problems: list[str]
99
164
  ) -> str:
@@ -103,10 +168,11 @@ def _replace_tables(
103
168
  for t in tables:
104
169
  problems.append(
105
170
  f"{_where(t, lines)}: a table (Substack has no table; pass "
106
- f"tables='code' to keep it as a code block, or render it to an image)"
171
+ f"tables='code' to keep it as a code block, or tables='image' to "
172
+ f"render it to a picture)"
107
173
  )
108
174
  return markdown
109
- render = _code_table if policy == "code" else policy
175
+ render = {"code": _code_table, "image": render_table_image}.get(policy, policy)
110
176
  if not callable(render):
111
177
  raise AdapterError(f"substack: unknown tables policy {policy!r}")
112
178
  for t in reversed(tables): # bottom-up, so earlier line numbers stay valid
@@ -139,9 +205,20 @@ def _image_problems(
139
205
  f"text, and Substack would keep only its alt text; give the image "
140
206
  f'its own paragraph and put the caption in the title: ![alt](src "caption")'
141
207
  )
208
+ nested = (
209
+ in_paragraph
210
+ and _is_image_only(tok.children)
211
+ and (tokens[i - 1].level > 0 or not tok.map)
212
+ )
142
213
  for img in images:
143
214
  src = img.attrs.get("src", "")
144
- if src.lower().startswith("data:"):
215
+ if nested and _is_local(src) and not src.lower().startswith("data:"):
216
+ yield (
217
+ f"{line}: a local image inside a list, quote or footnote "
218
+ f"({src}); Substack's editor is not known to accept those, so "
219
+ f"move it to the top level"
220
+ )
221
+ elif src.lower().startswith("data:"):
145
222
  yield f"{line}: an inline data: image cannot be uploaded; save it as a file"
146
223
  elif (
147
224
  _is_local(src)
@@ -150,6 +227,38 @@ def _image_problems(
150
227
  yield f"{line}: image file not found: {src} (looked in {base})"
151
228
 
152
229
 
230
+ def _absolute_images(markdown: str, tokens: list, assets_dir: Optional[str]) -> str:
231
+ """Rewrite each top-level image-only paragraph's local source to an absolute path.
232
+
233
+ python-substack resolves local image paths against the process's working
234
+ directory; absolute paths make conversion independent of it. Only images that
235
+ passed :func:`_image_problems` (so image-only top-level paragraphs) get here.
236
+ """
237
+ base = Path(assets_dir) if assets_dir else Path.cwd()
238
+ lines = markdown.splitlines(keepends=True)
239
+ edits = []
240
+ for i, tok in enumerate(tokens):
241
+ if tok.type != "inline" or not tok.children or not tok.map:
242
+ continue
243
+ kids = [c for c in tok.children if c.type != "softbreak"]
244
+ image = next((c for c in kids if c.type == "image"), None)
245
+ if image is None or not _is_local(image.attrs.get("src", "")):
246
+ continue
247
+ if image.attrs["src"].lower().startswith("data:"):
248
+ continue
249
+ path = (base / Path(unquote(image.attrs["src"])).expanduser()).resolve()
250
+ alt = image.content.replace("]", "\\]")
251
+ title = image.attrs.get("title")
252
+ title_part = f' "{title.replace(chr(34), chr(92) + chr(34))}"' if title else ""
253
+ text = f"![{alt}](<{quote(str(path))}>{title_part})"
254
+ if kids[0].type == "link_open":
255
+ text = f"[{text}]({kids[0].attrs.get('href', '')})"
256
+ edits.append((tok.map, text + "\n"))
257
+ for (start, end), text in reversed(edits): # bottom-up keeps line numbers valid
258
+ lines[start:end] = [text]
259
+ return "".join(lines)
260
+
261
+
153
262
  def _is_image_only(children: list) -> bool:
154
263
  kids = [c for c in children if c.type != "softbreak"]
155
264
  if len(kids) == 1 and kids[0].type == "image":
@@ -251,6 +360,7 @@ def preflight(
251
360
  f"substack: {len(problems)} problem(s) would lose content in the draft:\n"
252
361
  + "\n".join(f" - {p}" for p in problems)
253
362
  )
363
+ markdown = _absolute_images(markdown, tokens, assets_dir)
254
364
  return markdown, list(_html_inline_warnings(tokens, lines))
255
365
 
256
366
 
@@ -259,28 +369,6 @@ def preflight(
259
369
  # --------------------------------------------------------------------------- #
260
370
 
261
371
 
262
- _CWD_LOCK = threading.Lock()
263
-
264
-
265
- @contextmanager
266
- def _working_directory(path: Optional[str]) -> Iterator[None]:
267
- """python-substack resolves local image paths against the cwd.
268
-
269
- The cwd is process-wide, so conversions are serialized by a lock; a server
270
- that converts concurrently should first make image paths absolute instead.
271
- """
272
- if not path:
273
- yield
274
- return
275
- with _CWD_LOCK:
276
- previous = os.getcwd()
277
- os.chdir(path)
278
- try:
279
- yield
280
- finally:
281
- os.chdir(previous)
282
-
283
-
284
372
  def draft_document(
285
373
  article: Article,
286
374
  markdown: str,
@@ -291,7 +379,8 @@ def draft_document(
291
379
  ) -> dict:
292
380
  """The draft payload python-substack posts (``draft_body`` is a JSON string).
293
381
 
294
- With ``api=None`` nothing is uploaded and local image paths stay as they are.
382
+ ``markdown`` is what :func:`preflight` returned (its local image paths are
383
+ absolute). With ``api=None`` nothing is uploaded.
295
384
  """
296
385
  try:
297
386
  from substack.post import Post
@@ -303,8 +392,7 @@ def draft_document(
303
392
  user_id=user_id,
304
393
  audience=config.audience,
305
394
  )
306
- with _working_directory(article.assets_dir):
307
- post.from_markdown(markdown, api=api)
395
+ post.from_markdown(markdown, api=api)
308
396
  return post.get_draft()
309
397
 
310
398
 
@@ -423,6 +511,10 @@ def create_substack_draft(
423
511
  api = make_substack_api(secrets, publication_url=configured_url)
424
512
  publication = _publication_base(api, configured_url)
425
513
 
514
+ if config.draft_id is not None:
515
+ return _update_draft(
516
+ article, markdown, warnings, config=config, api=api, publication=publication
517
+ )
426
518
  draft = draft_document(
427
519
  article, markdown, config=config, api=api, user_id=api.get_user_id()
428
520
  )
@@ -465,12 +557,84 @@ def create_substack_draft(
465
557
  detail={
466
558
  "draft_id": draft_id,
467
559
  "edit_url": edit_url,
560
+ "revision": _revision_of(api, draft_id),
468
561
  "counts": summarize_document(draft),
469
562
  "warnings": warnings,
470
563
  },
471
564
  )
472
565
 
473
566
 
567
+ def _revision_of(api: Any, draft_id: Any) -> Optional[dict]:
568
+ """The draft's current revision (python-substack's conflict-check fingerprint), or None."""
569
+ try:
570
+ from substack.revision import compute_draft_revision
571
+
572
+ return compute_draft_revision(api.get_draft(draft_id))
573
+ except (
574
+ Exception
575
+ ): # an unreadable revision only makes the next update ask to overwrite
576
+ return None
577
+
578
+
579
+ def _update_draft(
580
+ article: Article,
581
+ markdown: str,
582
+ warnings: list[str],
583
+ *,
584
+ config: SubstackConfig,
585
+ api: Any,
586
+ publication: str,
587
+ ) -> PublishResult:
588
+ """Rewrite the existing draft ``config.draft_id`` from ``markdown``, never publishing."""
589
+ draft_id = config.draft_id
590
+ edit_url = f"{publication}/publish/post/{draft_id}"
591
+ tags = coalesce(config.tags, article.tags)
592
+ try:
593
+ result = api.update_draft_from_markdown(
594
+ draft_id,
595
+ markdown,
596
+ subtitle=article.subtitle or "",
597
+ audience=config.audience,
598
+ search_engine_description=article.description,
599
+ slug=article.slug,
600
+ draft_section_id=config.section_id,
601
+ tags=list(tags) if tags else None,
602
+ expected_revision=config.expected_revision,
603
+ allow_image_replacement=True, # the Markdown is the source of truth
604
+ allow_conflict=config.overwrite_edits,
605
+ )
606
+ except Exception as e:
607
+ hint = (
608
+ " (edited in Substack since the last run; pass overwrite_edits / "
609
+ "--overwrite-edits to replace those edits)"
610
+ if type(e).__name__ == "DraftConflictError"
611
+ else ""
612
+ )
613
+ raise AdapterError(
614
+ f"substack: updating draft {draft_id} ({edit_url}) failed: {e}{hint}"
615
+ ) from e
616
+ slug = ((result or {}).get("draft") or {}).get("slug") or article.slug
617
+ if slug != article.slug:
618
+ warnings.append(f"Substack changed the slug from {article.slug!r} to {slug!r}")
619
+ warnings.append(
620
+ "the title is not updated in place; change it in the Substack editor"
621
+ )
622
+ public_url = f"{publication}/p/{slug}"
623
+ return PublishResult.success(
624
+ SUBSTACK,
625
+ url=public_url,
626
+ status="draft",
627
+ canonical_url=public_url,
628
+ detail={
629
+ "draft_id": draft_id,
630
+ "edit_url": edit_url,
631
+ "updated_in_place": True,
632
+ "revision": _revision_of(api, draft_id),
633
+ "warnings": warnings,
634
+ },
635
+ )
636
+
637
+
474
638
  @register_adapter(SUBSTACK)
475
639
  async def publish(
476
640
  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(article, platforms=platforms)
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.4"
9
+ version = "0.1.5"
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,71 @@ 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
+ # --------------------------------------------------------------------------- #1-#5 follow-ups
370
+
371
+
372
+ def test_conversion_does_not_touch_the_working_directory(essay_dir, fake_api, monkeypatch):
373
+ """Local image paths become absolute during preflight, so no process-wide chdir."""
374
+ seen = []
375
+ real_chdir = os.chdir
376
+ monkeypatch.setattr(os, "chdir", lambda p: (seen.append(p), real_chdir(p)))
377
+ monkeypatch.chdir(essay_dir.parent)
378
+ seen.clear()
379
+ art = load_article(str(essay_dir / "essay.md"))
380
+ create_substack_draft(art, api=fake_api)
381
+ assert seen == []
382
+ assert fake_api.uploaded == [str((essay_dir / "images" / "fig 1.png").resolve())]
383
+
384
+
385
+ def test_preflight_makes_local_image_paths_absolute(tmp_path, png_bytes):
386
+ (tmp_path / "a b.png").write_bytes(png_bytes)
387
+ md, _ = preflight('![alt](<a b.png> "cap")\n', assets_dir=str(tmp_path))
388
+ assert str(tmp_path.resolve()) in unquote(md) and 'a%20b.png' in md and '"cap"' in md
389
+
390
+
391
+ def test_a_local_image_inside_a_list_is_refused(tmp_path, png_bytes):
392
+ (tmp_path / "a.png").write_bytes(png_bytes)
393
+ with pytest.raises(AdapterError, match="inside a list, quote or footnote"):
394
+ preflight("- ![alt](a.png)\n", assets_dir=str(tmp_path))
395
+ # a remote one is fine
396
+ preflight("- ![alt](https://example.com/a.png)\n", assets_dir=str(tmp_path))
397
+
398
+
399
+ def test_tables_image_policy_renders_a_png(tmp_path):
400
+ pytest.importorskip("PIL")
401
+ md, _ = preflight(
402
+ "| a | b |\n|---|---|\n| 1 | 2 |\n", tables="image", assets_dir=str(tmp_path)
403
+ )
404
+ assert md.startswith("![table](")
405
+ path = Path(unquote(md[len("![table](") : md.index(")")]).strip("<>"))
406
+ assert path.is_file() and path.read_bytes()[:4] == b"\x89PNG"
407
+
408
+
409
+ def test_a_draft_is_updated_in_place_with_its_revision_guard(essay_dir, fake_api):
410
+ calls = []
411
+ fake_api.update_draft_from_markdown = lambda draft_id, md, **kw: (
412
+ calls.append((draft_id, md, kw)) or {"draft": {"slug": "the-agile-religion"}}
413
+ )
414
+ art = load_article(str(essay_dir / "essay.md"))
415
+ config = SubstackConfig(draft_id=1234, expected_revision={"revision": "abc"})
416
+ result = create_substack_draft(art, config=config, api=fake_api)
417
+ ((draft_id, md, kwargs),) = calls
418
+ assert draft_id == 1234 and kwargs["expected_revision"] == {"revision": "abc"}
419
+ assert kwargs["allow_conflict"] is False and kwargs["slug"] == "the-agile-religion"
420
+ assert fake_api.posted == [] # no new draft
421
+ assert result.detail["updated_in_place"] and result.status == "draft"
422
+
423
+
424
+ def test_an_edited_draft_is_refused_with_a_hint(essay_dir, fake_api):
425
+ class DraftConflictError(ValueError):
426
+ pass
427
+
428
+ def conflict(*args, **kwargs):
429
+ raise DraftConflictError("remote draft has changed")
430
+
431
+ fake_api.update_draft_from_markdown = conflict
432
+ art = load_article(str(essay_dir / "essay.md"))
433
+ with pytest.raises(AdapterError, match="overwrite_edits"):
434
+ create_substack_draft(art, config=SubstackConfig(draft_id=1), api=fake_api)
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