Metadata-Version: 2.4
Name: wp_python
Version: 0.1.6
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


## 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"]                                  
  1. This was missing before, which is why the PyPI wheel was empty.                                                                              
  2. Bump the version in pyproject.toml:                                                                                     [project]
    version = "0.1.6"  # or whatever the next version is
  3. Build both sdist and wheel (clean dist/ first):        
    rm -rf dist/
    uv run hatchling build                                    
  4. Inspect the wheel before uploading — unzip it and check the RECORD contains actual package files:                                            
    unzip -l dist/*.whl | grep wp_python/
  4. 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.
  5. Upload to PyPI using twine (or uv publish): 
    with twine: uv run twine upload dist*                                           
    or with uv (0.4+): uv publish                                                
  5. Both will prompt for your PyPI token, or you can set TWINE_PASSWORD / UV_PUBLISH_TOKEN.                                                      
  6. Verify the published wheel by checking the PyPI page or installing into a throwaway venv:                                                    
  pip install --dry-run wp-python==<new-version>

  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.                                         