Metadata-Version: 2.4
Name: audora-wattpad-py
Version: 0.1.0
Summary: Async Wattpad API client implementing the audora-provider-base contract
Project-URL: Homepage, https://github.com/Archive-WP/audora-wattpad-py
Author-email: Aaron <aaron@audora.art>
License: MIT
Keywords: api,async,fiction,provider,wattpad
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: audora-provider-base==0.1.1
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# audora-wattpad-py

An async Wattpad API client for Python 3.12+, implementing the `BaseProvider` contract from
[`audora-provider-base`](https://forge.towu.dev/aaron/-/packages/pypi/audora-provider-base).

Because it implements a shared contract, code written against it ports to any other provider
in the family with no changes beyond the constructor.

> Unofficial. Wattpad publishes no public API; this is built against community
> reverse-engineering documented at
> [`Archive-WP/WattpadAPIDocumentation`](https://github.com/Archive-WP/WattpadAPIDocumentation).
> Endpoints can change without notice.

## Install

`audora-provider-base` lives in a Forgejo registry rather than on PyPI, so the index has to
be declared.

```bash
# uv reads [[tool.uv.index]] from pyproject.toml
uv add audora-wattpad-py

# pip needs it on the command line
pip install --extra-index-url https://forge.towu.dev/api/packages/aaron/pypi/simple \
    audora-wattpad-py
```

## Usage

```python
import asyncio

from audora_wattpad import WattpadProvider


async def main() -> None:
    async with WattpadProvider() as wp:
        story = await wp.get_story("336970902")
        print(story.title, "by", story.author)

        # Chapter stubs arrive with get_story; load_chapters fills in the text
        # in a single request via Wattpad's bulk endpoint.
        await wp.load_chapters(story, with_text=True)
        for chapter in story.chapters:
            print(chapter.title, len(chapter.text), "chars")


asyncio.run(main())
```

Authenticated use:

```python
async with WattpadProvider() as wp:
    await wp.authenticate("username", "password")

    library = await wp.get_library()
    async for story in wp.get_collection_stories(library):
        print(story.title)

    # Sessions are exportable, so you log in once rather than once per process.
    session = wp.export_session()  # contains secrets; store it like a password

# ... later, in another process
async with WattpadProvider() as wp:
    wp.restore_session(session)  # 0 requests
```

## Notes specific to Wattpad

- **Rate limiting.** Wattpad publishes no limits. This client defaults to a conservative
  2 requests/second; pass `limiter=` to override.
  ```python
  from audora_provider_base import TokenBucketLimiter

  WattpadProvider(limiter=TokenBucketLimiter(rate=5.0))
  ```
- **Caching.** No cache is configured by default. If you pass `cache=`, do not share one
  backend between authenticated and anonymous clients, or between two accounts — Wattpad
  returns per-account data (`voted`, `following`, drafts) from the same URLs.
- **Followers cap at 2000.** Wattpad refuses to page past 2000 followers. The paginator
  treats that as clean end-of-data rather than raising.
- **`UNSET` vs `None`.** A field that is `UNSET` was not part of the projection you
  requested; `None` means Wattpad says there is no value. Use
  `audora_provider_base.is_set()` when the difference matters.

## Development

```bash
uv sync --extra dev
rtk ruff format . && rtk ruff check --fix . && rtk mypy src/ tests/ && rtk pytest
```

### Testing

Offline tests need no network and no credentials:

```bash
rtk pytest -m "not live"
```

Live tests run against a real account and are **skipped when unconfigured**. Use a throwaway
account — the suite writes to it.

| Variable | Purpose |
|---|---|
| `WATTPAD_USERNAME`, `WATTPAD_PASSWORD` | Account credentials. Absent → all `live` tests skip. |
| `WATTPAD_TOKEN` | Use an existing session token; wins over username/password and skips the login request. |
| `WATTPAD_STORY_ID` | `story_ref` for the contract suite. **Must be owned by the account** and have at least one published chapter — the inline-image tests write into its first chapter. |
| `WATTPAD_MISSING_STORY_ID` | A story that does not exist. Default `1`. |
| `WATTPAD_USER` | `user_ref`. Defaults to the logged-in username. |
| `WATTPAD_LIST_ID` | A small (2–3 story) reading list owned by the account. |
| `WATTPAD_PART_ID` | A chapter to exercise. Defaults to the first part of `WATTPAD_STORY_ID`. |
| `WATTPAD_SEARCH_QUERY` | Query returning more than one page. Default `the`. |
| `WATTPAD_FOLLOW_TARGET` | A username to follow and unfollow. |
| `WATTPAD_ALLOW_WRITES` | Set to `1` to enable every mutating test. |

```bash
WATTPAD_USERNAME=... WATTPAD_PASSWORD=... WATTPAD_STORY_ID=... rtk pytest -m live
WATTPAD_ALLOW_WRITES=1 WATTPAD_USERNAME=... ... rtk pytest
rtk pytest -rs        # audit what skipped, and why
```

**Account setup the contract suite expects.** Verified against a real account; each of
these is a test that fails, rather than skips, if it is not met.

- **Email-verified.** An unverified account is refused with error 1073 on gated actions,
  `follow` among them.
- **Owns `WATTPAD_STORY_ID`**, which needs at least one *published* chapter. The image
  tests write into the first one, so read access is not enough.
- **Owns `WATTPAD_LIST_ID`**, a small reading list.
- **A library spanning more than one page** — more than 20 entries, so roughly 25. Below
  that the whole library fits in one page, no fan-out is ever required, and
  `test_load_stories_refuses_fanout` fails with nothing to refuse.

One caveat on reruns: `newstory` is idempotent on the title, so a test that creates a story
with a fixed name will keep resolving the first one, even after it is deleted. The suite
does not do this, but a scratch script easily might.

## Licence

MIT.
