Metadata-Version: 2.4
Name: audora-wattpad-py
Version: 1.0.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 :: 5 - Production/Stable
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<2.0.0,>=1.2.0
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

Both this package and `audora-provider-base` live in a Forgejo registry rather than on
PyPI, so the index has to be declared. With uv, add it to your project's
`pyproject.toml` first:

```toml
[[tool.uv.index]]
name = "forgejo"
url = "https://forge.towu.dev/api/packages/aaron/pypi/simple"
```

```bash
uv add audora-wattpad-py

# pip takes the index on the command line instead
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("26327373")
        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
```

Beyond these two snippets, every feature has a minimal runnable example in
[`tests/simple-tests/`](https://github.com/Archive-WP/audora-wattpad-py/tree/main/tests/simple-tests)
— one tiny script per method, each hitting the live API and printing what it got, with
[`guide.md`](https://github.com/Archive-WP/audora-wattpad-py/blob/main/tests/simple-tests/guide.md)
documenting every script and the environment variables each one reads. Copy the one
closest to what you are building.

## 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=`, every request
  except login follows the backend's policy — chapter text and the bulk story ZIP
  included, so a persistent backend makes re-downloading a story cost 0 requests. The
  library imposes no cache policy of its own, which makes backend isolation your job:
  do not share one backend between authenticated and anonymous clients, or between two
  accounts — Wattpad returns per-account data (`voted`, `following`, drafts, paid
  chapter text) from the same URLs it serves anonymously.
- **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. The pair also feeds the contract suite's authenticate round-trip. |
| `WATTPAD_TOKEN` | Use an existing session token; wins over username/password and skips the login request. **The authenticate round-trip test logs in with the password and Wattpad then invalidates older tokens — so a cached token is dead after any password run.** |
| `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_USER` | `user_ref`. Defaults to the logged-in username. |
| `WATTPAD_LIST_ID` | A small (2–3 story) reading list owned by the account. |
| `WATTPAD_ADDABLE_STORY_ID` | A story that exists but is in **neither** `WATTPAD_LIST_ID` **nor the account's library** — the membership tests add it and remove it again, on both surfaces. Absent → those tests skip. |
| `WATTPAD_PART_ID` | A chapter for the `simple-tests` scripts to exercise (the contract suite does not read it). Defaults to the first part of `WATTPAD_STORY_ID`. |
| `WATTPAD_SEARCH_QUERY` | Query returning more than one page. Default `harry potter` — deliberately a query that multi-pages with or without a session language context. A generic word like `the` is answered from that context and can legitimately come back empty mid-suite. |
| `WATTPAD_FOLLOW_TARGET` | A username the follow round-trip may follow and unfollow (also used by `simple-tests/follow_unfollow.py`). Pick one whose follow state nobody cares about — the official `wattpad` account is a good choice. Absent → the follow round-trip skips. |
| `WATTPAD_COMMENTED_PART_ID` | Any busy **public** chapter whose early paragraphs carry inline comments — the paragraph-comments test reads it, never writes. Absent → that test skips. |
| `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.
- **`WATTPAD_ADDABLE_STORY_ID` out of the library** — the library membership tests
  assert it is absent before adding it, and fail rather than skip when it is already
  there.

One caveat on reruns: `newstory` is idempotent on the title, so anything that creates a
story with a fixed name will keep resolving the first one, even after it is deleted. The
suite's authoring lifecycle salts its scratch title with a uuid for exactly this reason;
a scratch script easily might not.
