Skip to main content

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):

  1. In WordPress admin go to Users → Profile → Application Passwords
  2. Generate a password for your app
  3. 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

  1. Verify pyproject.toml has the source layout configured (/home/andrew/projects/wp_python/pyproject.toml):
    [tool.hatch.build.targets.wheel] packages = ["src/wp_python"]
  2. This was missing before, which is why the PyPI wheel was empty.
  3. Bump the version in pyproject.toml: [project] version = "0.1.6" # or whatever the next version is
  4. Build both sdist and wheel (clean dist/ first):
    rm -rf dist/ uv run hatchling build
  5. Inspect the wheel before uploading — unzip it and check the RECORD contains actual package files:
    unzip -l dist/*.whl | grep wp_python/
  6. 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.
  7. Upload to PyPI using twine (or uv publish): with twine: uv run twine upload dist*
    or with uv (0.4+): uv publish
  8. Both will prompt for your PyPI token, or you can set TWINE_PASSWORD / UV_PUBLISH_TOKEN.
  9. 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

wp_python-0.1.6.tar.gz (27.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wp_python-0.1.6-py3-none-any.whl (47.7 kB view details)

Uploaded Python 3

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

Hashes for wp_python-0.1.6.tar.gz
Algorithm Hash digest
SHA256 d1dfdd052020b7034a205a9f3d39905f11f1636e5df04ec3f3b31e552168526c
MD5 922d6ff53de3877e5415aee7e791ac8e
BLAKE2b-256 479fbcbecaf6d5a23a0be4774643e4691cdf70c7e79773b176c3c3ef8f0b2c7d

See more details on using hashes here.

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

Hashes for wp_python-0.1.6-py3-none-any.whl
Algorithm Hash digest
SHA256 bd6f10402a8bc377f5e7df8348105309029c42fe4ebfcd049f7fef5fe4d0afc0
MD5 590dad615f6596d09f2de0551123d9e0
BLAKE2b-256 803b69a6cbcab0432b467303072a7777f5db50a3b4be7f81e0147dea810a1f08

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page