Python client for the Markpost API (sync + async, typed)
Project description
English | 简体中文
Installation
Requires Python >=3.10.
uv add markpost
# or
pip install markpost
Quick Start
Sync
from markpost import Markpost
with Markpost("https://markpost.cc", "alice", "secret") as client:
created = client.create_post("Hello", "# Body in **markdown**")
print(created.id) # "p-<nanoid>"
html = client.get_post(created.id) # full HTML page (str)
md = client.get_post(created.id, format="raw") # "# Hello\n\n..." (str)
page = client.list_posts(limit=20)
for item in page.items:
print(item.qid, item.title)
Async
from markpost import AsyncMarkpost
async with AsyncMarkpost("https://markpost.cc", "alice", "secret") as client:
created = await client.create_post("Hello", "# Body")
html = await client.get_post(created.id)
page = await client.list_posts(limit=20)
Authentication
Pass username + password to the constructor to auto-login (sync logs in
immediately; async logs in lazily on the first call or __aenter__):
client = Markpost("https://markpost.cc", username="alice", password="secret")
Or log in manually:
client = Markpost("https://markpost.cc")
result = client.login("alice", "secret")
print(result.user.role, result.token)
The access token is refreshed automatically when it is about to expire or
when the backend returns 401. Concurrent refreshes are deduplicated into a
single backend call (single-flight), which is essential because the backend
rotates refresh tokens one-time and revokes the whole session on reuse.
Posts
create_post authenticates with a post key (not a JWT). If you don't pass
one, the client fetches and caches your post key automatically.
created = client.create_post("Title", "body") # auto-fetches post key
created = client.create_post("Title", "body", post_key="mpk-...")
get_post returns a str (HTML by default, or markdown when format="raw").
It supports conditional requests via If-None-Match:
etag = "...from a previous response..."
result = client.get_post("p-abc", if_none_match=etag)
# result is None when the backend returns 304 (not modified)
Pagination uses a flat structure:
page = client.list_posts(page=2, limit=50)
# page.items, page.total, page.page, page.limit, page.total_pages
Delivery channels & history
ch = client.create_channel(
kind="feishu",
name="ops-alerts",
configuration={"webhook_url": "https://...", "card_link_url": "https://..."},
keywords="", # optional keyword filter expression
)
# PATCH semantics: pass only the fields you want to change.
client.update_channel(ch.id, enabled=False)
channels = client.list_channels() # list[Channel] (no pagination)
history = client.list_delivery_history(channel_id=ch.id) # Page[DeliveryHistoryItem]
latest = client.list_latest_delivery() # list[DeliveryHistoryItem], one per channel
# Send a diagnostic card to verify the webhook is wired up. Fire-and-forget:
# the backend sends it synchronously but writes no delivery_history row.
client.test_channel(ch.id)
Error handling
Errors are a typed hierarchy rooted at MarkpostError:
from markpost import (
MarkpostError, APIError,
BadRequestError, AuthenticationError, PermissionDeniedError,
NotFoundError, ConflictError, UnprocessableEntityError, RateLimitError,
InternalServerError, APITimeoutError, APIConnectionError,
)
try:
client.create_post("", "body")
except UnprocessableEntityError as e:
print(e.status_code, e.code) # 422 "title_too_long" / "required" / ...
for fe in e.errors: # parsed per-field details
print(fe.field, fe.code, fe.message)
except RateLimitError as e:
print(e.limit, e.remaining, e.reset) # parsed from RateLimit-* headers
except AuthenticationError as e:
print(e.code) # "invalid_credentials", "invalid_token", ...
except APIError as e:
print(e.status_code, e.code, e.message)
Timeouts and network errors map to APITimeoutError / APIConnectionError
(both retried automatically). 5xx and 429 are retried; 4xx are not.
Configuration
Markpost(
base_url,
username=None,
password=None,
*,
timeout=None, # float | httpx.Timeout (defaults to a safe connect/read/write/pool split)
max_retries=2, # 0 disables retries
post_key=None, # pre-seed a post key
http_client=None, # inject a custom httpx client (testing)
verify=True, # set False for the self-signed e2e container
)
License
MIT License — see LICENSE.txt.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file markpost-0.2.0rc2.tar.gz.
File metadata
- Download URL: markpost-0.2.0rc2.tar.gz
- Upload date:
- Size: 122.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8666416dea8c9d5de7adb4740f3d14ea047854745f5f963c5cd88d34627522df
|
|
| MD5 |
f39fc7fc20f2f439a349fa851374b213
|
|
| BLAKE2b-256 |
2e04de45983ff4978e646afe4c88c3c1b9ec20158611fb13c698a5588f9876e2
|
File details
Details for the file markpost-0.2.0rc2-py3-none-any.whl.
File metadata
- Download URL: markpost-0.2.0rc2-py3-none-any.whl
- Upload date:
- Size: 23.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b9d664da1910debcd039648714e79ec1a44ff69ddf7a7f0b78ab79d8d2e5091
|
|
| MD5 |
2f88b42fab81d848b4c6dfe97da8f84e
|
|
| BLAKE2b-256 |
0e2663d1c873bf3b5f7f842a70965d7fd1e23f22bc5005e69588bb3acd7d7bff
|