A Python client for interacting with the WordPress REST API.
Project description
wp-python
A Python 3.12 client for the WordPress REST API.
Installation
pip install wp-python
Quick start
from wp_python import WordPressClient, ApplicationPasswordAuth
auth = ApplicationPasswordAuth("username", "xxxx xxxx xxxx xxxx xxxx xxxx")
client = WordPressClient("https://your-site.com", auth=auth)
# Standard endpoints
posts = client.posts.list()
me = client.users.me()
page = client.pages.get(5)
# Custom post types
products = client.custom_post_type("products")
items = products.list(per_page=20, status="publish")
item = products.get(42)
new_item = products.create({"title": "Widget", "status": "publish"})
products.update(42, {"title": "Updated Widget"})
products.delete(42, force=True)
Project structure
src/wp_python/
├── __init__.py # Public exports
├── client.py # WordPressClient + CustomPostTypeEndpoint
├── auth.py # Auth handlers
├── exceptions.py # Typed exceptions
├── transport.py # HTTP layer (HttpxTransport, Transport protocol)
├── paginated_result.py # PaginatedResult container
├── models/ # Pydantic models (Post, Page, User, …)
└── endpoints/ # Typed endpoint classes (posts, users, …)
Authentication
WordPress Application Passwords (recommended for the REST API):
- In WordPress admin go to Users → Profile → Application Passwords
- Generate a password for your app
- Use it with
ApplicationPasswordAuth:
from wp_python import ApplicationPasswordAuth, WordPressClient
auth = ApplicationPasswordAuth("andrew", "naAg I4sg dwFI R9PC V06P 1a1o")
client = WordPressClient("https://example.com", auth=auth)
Other supported auth types: BasicAuth, JWTAuth, OAuth2Auth.
Typed endpoints
Standard WordPress resources are exposed as typed endpoints on the client.
All list() methods return a PaginatedResult — a list-like object that also
carries total, total_pages, has_next, and has_prev:
result = client.posts.list(per_page=10)
for post in result:
print(post.title.rendered)
if result.has_next:
next_page = client.posts.list(page=result.page + 1)
print(f"{len(result)} of {result.total} posts")
Use iterate_all() to page through everything without managing page numbers:
for post in client.posts.iterate_all(per_page=100):
print(post.id)
Custom post types
client.custom_post_type(slug) returns a CustomPostTypeEndpoint that
supports the same CRUD operations but returns raw dict objects (since the
schema is unknown at construction time):
cpt = client.custom_post_type("restart-registry")
posts = cpt.list(author=1, status="any") # list[dict]
post = cpt.get(13) # dict
new = cpt.create({"title": "My Registry", "status": "publish"})
upd = cpt.update(13, {"status": "private"})
cpt.delete(13, force=True)
Embedding linked resources (embed option)
WordPress's _embed query parameter tells the REST API to inline linked
resources — such as the author object — directly in the response body under
_embedded, saving extra round-trips.
Pass embed at construction time to inject _embed into every request
made through that endpoint (list, get, create, and update):
cpt = client.custom_post_type("restart-registry", embed="author")
posts = cpt.list() # GET /wp/v2/restart-registry?_embed=author&…
post = cpt.get(13) # GET /wp/v2/restart-registry/13?_embed=author
new = cpt.create(…) # POST /wp/v2/restart-registry?_embed=author
upd = cpt.update(…) # PUT /wp/v2/restart-registry/13?_embed=author
The author slug is then available at:
post["_embedded"]["author"][0]["slug"]
embed accepts the same values as the WordPress _embed query parameter:
| Value | Effect |
|---|---|
"author" |
Embed author object only |
"wp:term" |
Embed taxonomy terms only |
True |
Embed all linked resources |
None (default) |
No embedding; standard response |
WordPress has supported _embed on write operations (POST/PUT) since 5.4.
delete() intentionally never sends _embed; the deletion response body
does not include linked resources regardless.
A per-call kwarg takes precedence over the endpoint-level default:
cpt = client.custom_post_type("products", embed="author")
cpt.list(_embed="wp:term") # sends _embed=wp:term, not author
Design note — why endpoint-level rather than per-call?
For: The primary motivation is eliminating N identical kwarg repetitions
across every call site when a project consistently needs linked data from a
specific CPT. In restart-lambda, for example, five separate calls to a
restart-registry endpoint all need _embed=author to resolve the registry
owner's username. Setting it once at construction keeps call sites clean and
removes a class of bug where a new call site forgets the kwarg.
Against: Endpoint-level state is invisible at the call site. A reader
seeing cpt.get(42) has no immediate signal that the response will contain
_embedded data. Per-call kwargs (cpt.get(42, _embed="author")) are more
explicit and consistent with how every other optional WP REST parameter is
passed through **kwargs. They also avoid the edge case where a single
endpoint instance is shared and some calls genuinely should not embed.
The per-call override mechanism (last example above) exists precisely because endpoint-level defaults are not always right for every call. If your code has only one or two CPT call sites that need embed, prefer per-call kwargs instead.
Error handling
from wp_python.exceptions import (
AuthenticationError, # 401
PermissionError, # 403
NotFoundError, # 404
ValidationError, # 400
RateLimitError, # 429
ServerError, # 5xx
WordPressError, # base class
)
try:
post = client.posts.get(99999)
except NotFoundError:
print("Post not found")
except PermissionError:
print("Not authorised")
except WordPressError as e:
print(f"API error {e.status_code}: {e.message}")
Context manager
WordPressClient implements __enter__ / __exit__ and can be used as a
context manager to ensure the underlying connection pool is always closed:
with WordPressClient("https://example.com", auth=auth) as client:
posts = client.posts.list()
Transport layer
HTTP logic lives in HttpxTransport, separate from WordPressClient and the
endpoint classes. You can wrap it to add retry behaviour, logging, or swap in
a fake for tests:
from wp_python.transport import HttpxTransport
class RetryTransport:
def __init__(self, inner, max_retries=3): ...
def request(self, method, path, **kwargs): ...
def close(self): ...
client.transport = RetryTransport(client.transport)
Dependencies
- httpx — HTTP client
- pydantic — data validation and model serialisation
Publishing
Building and publishing wp_python to PyPI
- Verify pyproject.toml has the source layout configured (/home/andrew/projects/wp_python/pyproject.toml):
[tool.hatch.build.targets.wheel] packages = ["src/wp_python"] - This was missing before, which is why the PyPI wheel was empty.
- Bump the version in pyproject.toml: [project] version = "0.1.6" # or whatever the next version is
- Build both sdist and wheel (clean dist/ first):
rm -rf dist/ uv run hatchling build - Inspect the wheel before uploading — unzip it and check the RECORD contains actual package files:
unzip -l dist/*.whl | grep wp_python/ - You should see wp_python/init.py, wp_python/client.py, etc. If you only see dist-info files, the packaging config is still wrong — don't publish.
- Upload to PyPI using twine (or uv publish):
with twine: uv run twine upload dist*
or with uv (0.4+): uv publish - Both will prompt for your PyPI token, or you can set TWINE_PASSWORD / UV_PUBLISH_TOKEN.
- Verify the published wheel by checking the PyPI page or installing into a throwaway venv:
pip install --dry-run wp-python==
The critical gate is step 4: always inspect the wheel's RECORD before uploading. If you see only .dist-info/ entries and no wp_python/*.py, don't publish.
Project details
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 wp_python-0.1.6.tar.gz.
File metadata
- Download URL: wp_python-0.1.6.tar.gz
- Upload date:
- Size: 27.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d1dfdd052020b7034a205a9f3d39905f11f1636e5df04ec3f3b31e552168526c
|
|
| MD5 |
922d6ff53de3877e5415aee7e791ac8e
|
|
| BLAKE2b-256 |
479fbcbecaf6d5a23a0be4774643e4691cdf70c7e79773b176c3c3ef8f0b2c7d
|
File details
Details for the file wp_python-0.1.6-py3-none-any.whl.
File metadata
- Download URL: wp_python-0.1.6-py3-none-any.whl
- Upload date:
- Size: 47.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd6f10402a8bc377f5e7df8348105309029c42fe4ebfcd049f7fef5fe4d0afc0
|
|
| MD5 |
590dad615f6596d09f2de0551123d9e0
|
|
| BLAKE2b-256 |
803b69a6cbcab0432b467303072a7777f5db50a3b4be7f81e0147dea810a1f08
|