Metadata-Version: 2.4
Name: wp_python
Version: 0.1.5
Summary: A Python client for interacting with the WordPress REST API.
Author-email: Andrew <andrew.neher1@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.12
Requires-Dist: hatch>=1.16.3
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.12.5
Requires-Dist: pytest-mock>=3.15.1
Requires-Dist: pytest>=9.0.2
Requires-Dist: respx>=0.22.0
Description-Content-Type: text/markdown

# wp-python

A Python 3.12 client for the WordPress REST API.

## Installation

```bash
pip install wp-python
```

## Quick start

```python
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`:

```python
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`:

```python
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:

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

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

```python
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:

```python
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:

```python
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

```python
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:

```python
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:

```python
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
