Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: ma2za/python-substack
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: main
Choose a base ref
...
head repository: ericpberry/python-substack
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 5 commits
  • 19 files changed
  • 2 contributors

Commits on May 26, 2026

  1. chore: add scripts for sandbox verification and HAR extraction

    - scripts/verify_baseline.py exercises auth, draft create/publish/delete
      via the FastMCP tool surface against the configured sandbox publication.
    - scripts/extract_har.py prints a digestible per-entry summary of a HAR
      capture (URL, method, status, headers, body), useful for reverse-engineering
      Substack flows.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    ericpberry and claude committed May 26, 2026
    Configuration menu
    Copy the full SHA
    19776ee View commit details
    Browse the repository at this point in the history
  2. feat(api): add upload_podcast_audio for podcast audio upload + transcode

    Implements the full Substack podcast audio upload sequence as a single
    public method on Api:
    
      1. POST /audio/upload to initiate (returns media UUID, multipart upload
         id, and pre-signed S3 PUT URL(s))
      2. PUT raw audio bytes to each pre-signed S3 URL (single part today;
         loop iterates so future multi-part upload requires no changes)
      3. POST /audio/upload/{uuid}/transcode with locally-computed mutagen
         duration + S3 etags (quotes preserved) to finalize multipart upload
         and trigger async transcoding
      4. Poll GET /audio/upload/{uuid} until state == "transcoded"
    
    The S3 PUT is the only HTTP call in the codebase that does not route
    through Api._handle_response: S3 returns an empty body on success and
    the signal we need (ETag header) is in response.headers, not the body.
    On S3 failure we raise SubstackRequestException with status code and
    truncated body to preserve diagnostic info.
    
    Dependencies:
    - mutagen (^1.47) added as a runtime dep; reads MP3 duration headers
      client-side without ffmpeg
    - lameenc (^1.8) added as a dev dep; conftest uses it to generate a
      tiny silent MP3 fixture for the integration test (pure-Python wheels,
      no ffmpeg system dependency)
    - pytest (^9.0) added explicitly to dev deps
    
    Tests:
    - 22 unit tests in tests/substack/test_audio_upload.py mock at the
      requests.Session level (deliberate departure from the codebase's
      no-mock convention -- documented in docs/STYLE.md)
    - 1 integration test gated on RUN_INTEGRATION_TESTS=1 with the new
      "integration" pytest marker; creates a real podcast draft on the
      configured sandbox, uploads the fixture, verifies transcoded state
      and non-zero duration, deletes the draft in a finally block
    
    Tooling:
    - "integration" marker registered in pyproject.toml so default
      pytest runs (or -m "not integration") cleanly skip it
    - tests/fixtures/ added to .gitignore (regenerated on demand)
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    ericpberry and claude committed May 26, 2026
    Configuration menu
    Copy the full SHA
    f1f0c6c View commit details
    Browse the repository at this point in the history
  3. feat(podcast): add PodcastPost class for podcast draft assembly

    PodcastPost is the podcast-side counterpart to Post: in-memory state for
    a Substack podcast draft, mirroring Post's `vars(self)` serialization so
    every public attribute maps directly to a Substack API field.
    
    Key differences from Post:
    - Holds two distinct ProseMirror documents: `draft_body` (post-page body,
      same field Post uses) and `podcast_description` (show notes shown
      alongside the audio player). Both are optional, both JSON-encoded by
      get_draft.
    - Carries podcast-specific fields: `type="podcast"`, `draft_podcast_upload_id`,
      `draft_podcast_url`, `draft_podcast_duration`.
    - Carries `last_updated_at` for optimistic concurrency on multi-step
      flows (create -> PUT updates). Initially None and omitted from
      get_draft; callers set it from the previous draft response's
      `draft_updated_at` before each subsequent PUT.
    - get_draft is non-destructive (shallow-copies vars(self) before
      JSON-encoding) so the same instance can be reused across create / PUT /
      publish. Post has a known footgun where get_draft replaces
      self.draft_body in place; PodcastPost avoids it.
    
    Composition over inheritance: the markdown setters
    (set_body_from_markdown, set_show_notes_from_markdown) construct a
    throwaway Post instance, run from_markdown, and steal the resulting
    draft_body dict. Reuses the full Post markdown parser (headings, lists,
    inline formatting, links, code blocks, blockquotes, etc.) without
    duplicating any of it. Both setters accept an optional `api` parameter
    forwarded to Post.from_markdown for inline image upload.
    
    Builders return self: set_section, set_audio, set_body_from_markdown,
    set_show_notes_from_markdown -- consistent with STYLE.md section 10.
    
    No new Api method needed -- post_draft and put_draft already accept
    arbitrary JSON bodies / kwargs, so the existing endpoints handle
    podcast drafts unchanged. type="podcast" in the create body is the only
    discriminator the server needs.
    
    Re-exported from `substack` package top level alongside Api.
    
    Tests:
    - 47 unit tests in tests/substack/test_podcast.py (pure unit, no mocks):
      construction defaults, get_draft serialization (both docs JSON-encoded,
      last_updated_at omit-when-None / include-when-set, non-destructive
      repeat calls), markdown setters delegate to Post correctly without
      cross-contaminating the two docs, set_audio/set_section/fluent
      chaining, last_updated_at lifecycle.
    - 1 integration test gated on RUN_INTEGRATION_TESTS=1 that creates a
      podcast draft against the sandbox, uploads a fixture MP3 via
      Api.upload_podcast_audio, attaches via PUT /drafts, and asserts the
      server echoes title/subtitle/audio UUID/both ProseMirror docs AND
      that draft_updated_at advances after the PUT (proof that
      last_updated_at threading is actually round-tripping, not just
      satisfying mock assertions). Cleanup in a finally block. No publish.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    ericpberry and claude committed May 26, 2026
    Configuration menu
    Copy the full SHA
    a1b6598 View commit details
    Browse the repository at this point in the history
  4. feat(podcast): wire up publish flow + end-to-end example

    No new Api methods needed. Both Api.prepublish_draft and Api.publish_draft
    are generic over draft type (no podcast-specific branching at either
    endpoint; the Substack server is tolerant of the share_automatically key
    on podcast publish even though the captured frontend omits it). No
    client-side guard-rails added either -- the /prepublish endpoint is the
    authoritative validator and duplicating it here would drift from server
    truth.
    
    What ships:
    
    - tests/substack/test_podcast_publish_integration.py: full end-to-end
      flow against the sandbox -- create podcast draft, upload tiny fixture
      MP3, attach via PUT with last_updated_at threading, prepublish (assert
      errors=[]), publish with send=False (no email goes out), assert
      is_published=True / type=podcast / audio reference preserved, delete
      the post in a finally block. Gated on RUN_INTEGRATION_TESTS=1 and the
      `integration` marker.
    
    - examples/publish_podcast.py: argparse-driven example mirroring
      examples/publish_markdown.py style. Demonstrates the canonical six-step
      flow (build PodcastPost, create draft, upload audio, attach, prepublish,
      publish) with --publish/--no-send/--audience flags and optional
      --body-file / --show-notes-file Markdown inputs. Defaults are safe
      (stops at draft stage unless --publish is given).
    
    With this commit, the library can publish a podcast end-to-end purely
    through the Api + PodcastPost surface, with no MCP layer required. The
    MCP tool wrappers are Phase F.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    ericpberry and claude committed May 26, 2026
    Configuration menu
    Copy the full SHA
    8b37b2a View commit details
    Browse the repository at this point in the history
  5. feat(mcp): add podcast tools (upload + end-to-end create-and-publish)

    Two new FastMCP tools matching the existing tool-surface pattern
    (async def, full type hints, full prose docstrings with Args/Returns/
    Examples):
    
    - upload_podcast_audio(file_path, draft_id): thin wrapper around
      Api.upload_podcast_audio. The audio UUID returned is what attaches
      the upload to a draft.
    
    - post_podcast_draft_from_markdown(...): composes the full podcast
      flow in a single call -- build PodcastPost, post_draft, optionally
      upload + attach audio (threading last_updated_at from the create
      response into the attach PUT, matching the architecture's
      optimistic-concurrency guidance), optionally tag, optionally
      prepublish, optionally publish. show_notes_markdown and
      post_body_markdown are independently optional and populate the two
      distinct ProseMirror fields on a podcast draft. Returns a 5-key dict
      ({draft, upload, tags, prepublish, publish}) surfacing each composed
      step's result.
    
    No client-side guard-rails added (no "must have audio" / "must have
    title" checks). prepublish_draft is the authoritative validator and
    duplicating its logic here would drift from server truth.
    
    Tests:
    
    - tests/substack_mcp/test_mcp_server_podcast.py: 33 unit tests covering
      PodcastPost construction wiring, the two-ProseMirror-doc split, audio
      attach flow (including the critical last_updated_at threading
      assertion that reads from put_draft.call_args.kwargs to catch the
      "attribute set but stripped by get_draft" bug class), tags
      normalization, prepublish/publish gating, return shape, and the full
      call ordering (post_draft -> upload -> put -> tags -> prepublish ->
      publish). Mocks get_api per the pattern flagged in
      tests/substack/test_audio_upload.py.
    
    - tests/substack_mcp/test_mcp_podcast_integration.py: the v1 acceptance
      test. One MCP call takes MP3 + Markdown for body + show notes,
      creates a podcast draft, uploads the audio, attaches via PUT,
      prepublishes, publishes with send=False, and deletes via finally
      block. Gated on RUN_INTEGRATION_TESTS=1.
    
    README:
    
    - Fix wrong MCP path reference (substack/mcp_fastmcp.py was already
      wrong before this commit; it lives in substack_mcp/mcp_server.py).
    - Add "Publishing a Podcast Episode" section showing the Api +
      PodcastPost flow end-to-end, with a pointer to examples/publish_podcast.py.
    - Split the MCP tools list into Newsletter and Podcast subsections;
      document both new tools.
    
    With this commit, podcast publishing works end-to-end from both the
    direct-Python and MCP surfaces. The complete phase sequence:
      Phase C: Api.upload_podcast_audio
      Phase D: PodcastPost
      Phase E: publish flow + example
      Phase F: MCP tool wrappers
    constitutes v1.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    ericpberry and claude committed May 26, 2026
    Configuration menu
    Copy the full SHA
    233c707 View commit details
    Browse the repository at this point in the history
Loading