Metadata-Version: 2.5
Name: audora-inkitt-py
Version: 0.0.1
Summary: Async Inkitt API client implementing the audora-provider-base contract
Project-URL: Homepage, https://github.com/Archive-WP/audora-inkitt-py
Author-email: Aaron <aaron@audora.art>
License: Proprietary
Keywords: api,async,fiction,inkitt,provider
Classifier: Development Status :: 4 - Beta
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.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-inkitt-py

An async Inkitt 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. Inkitt publishes no API; this client is built from community
> reverse-engineering plus live probing, and endpoints can change without notice. Where
> live behaviour contradicts the documentation, live wins — every such deviation is
> recorded on the method that encodes it.

## 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-inkitt-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-inkitt-py
```

## Usage

The whole reader surface works anonymously — story detail, chapters with full text,
search, comments, reviews, reading lists:

```python
import asyncio

from audora_inkitt import InkittProvider


async def main() -> None:
    async with InkittProvider() as inkitt:
        story = await inkitt.get_story(1729326)
        await inkitt.load_chapters(story, with_text=True)
        print(story.title, "—", len(story.chapters or []), "chapters")

        async for hit in inkitt.search_stories("dragons"):
            print(hit.title)
            break


asyncio.run(main())
```

Authenticated use unlocks the account surface — recommendations, notifications, the
reading-list write lifecycle, and authoring:

```python
async with InkittProvider() as inkitt:
    await inkitt.authenticate("username-or-email", "password")
    lists = await inkitt.list_collections(inkitt.account_user_id()).all()
```

Sessions round-trip through `export_session()` / `restore_session()`. Inkitt's tokens are
stable — a fresh login returns the *same* token rather than invalidating older ones.

## Notes specific to Inkitt

- **Anonymous chapter text is a preview.** Without a session, `load_chapters(...,
  with_text=True)` carries text for only the first few chapters while still claiming
  access; any logged-in account (no purchase needed) receives the whole book. Check
  `story.chapters_loaded` rather than trusting the quota.
- **Ids are numeric.** Usernames cannot address the user routes — resolve them through
  `search_users` first. Chapters additionally need story context: pass a `Chapter`
  parsed from its story, the `story=` hint, or spell both halves as
  `"storyId:chapterId"`.
- **Rate limiting.** Nothing is documented, but a limiter exists — requests are
  throttled to 2/s by default, and sustained multi-hundred-page walks can still meet
  429s.
- **Caching.** Pass `cache=` at your own policy; the package imposes none. A cache
  shared across two accounts can leak private reads between them.
- **Paragraph comments serve counts only.** The platform never serves inline-comment
  bodies to readers, so the canonical `PARAGRAPH_COMMENTS` capability is not declared;
  `get_paragraph_comment_counts` exposes what exists.
- **`UNSET` vs `None`** (base vocabulary): `UNSET` means the projection never carried
  the field; `None` means the platform explicitly said null.

## Development

```bash
uv sync --extra dev
uv run ruff format --check . && uv run ruff check .
uv run mypy src/ tests/
uv run pytest
AUDORA_PROVIDER_BASE_STRICT=1 uv run pytest
```

The quality-gate CI runs the same chain on every push. See `AGENTS.md` for the
repository conventions, the architecture, and the platform invariants.

### Testing

Offline tests need no network and no credentials — `pytest -m "not live"` (and the plain
default run) stays green on a fresh clone.

Live tests run against a real Inkitt account and are **skipped when unconfigured**. Use a
throwaway account: with writes enabled the suite creates and deletes real content, and
wall posts cannot be deleted at all.

| Variable | Purpose |
|---|---|
| `INKITT_USERNAME`, `INKITT_PASSWORD` | Account credentials (the login route accepts a username in the email field). Absent → all `live` tests skip. The pair also feeds the authenticate round-trip, and the wrong-password probe reuses the real `INKITT_USERNAME` — **never an invented address: the login route is login-or-SIGNUP and would create the account**. |
| `INKITT_TOKEN` | Use an existing session token; wins over the password and skips the login request. Pair with `INKITT_USER_ID` (and optionally `INKITT_USERNAME`) so the session knows its identity. |
| `INKITT_USER_ID` | The account's own numeric id — `user_ref`/`own_user_ref`, and the wall-lifecycle target (wall posts land on the account's own wall). |
| `INKITT_STORY_ID` | `story_ref`: any public story with several chapters; it need not be owned (the authoring tests build their own scratch story). Also the reading-position write target. |
| `INKITT_LIST_ID` | `collection_ref`: a reading list **owned by the account**. |
| `INKITT_ADDABLE_STORY_ID` | A story that exists but is **not** in `INKITT_LIST_ID` — the membership tests add and remove it (Inkitt rejects adding a story already in a list). |
| `INKITT_FOLLOW_TARGET` | A numeric user id whose follow state nobody cares about — the round trip follows and unfollows it. |
| `INKITT_COMMENTED_CHAPTER_ID` | The `"storyId:chapterId"` form — Inkitt cannot address a chapter by id alone. Keep the story half equal to `INKITT_STORY_ID`; the reading-position write pairs them. |
| `INKITT_LIKEABLE_STORY_ID` | A QUIET story for the like round trip — the only read-back is the like count, and a busy story's moving count flakes. |
| `INKITT_SEARCH_QUERY` | A query returning multiple pages. Default `romance`. |
| `INKITT_ALLOW_WRITES` | Set to `1` to enable every mutating test — the second gate. Holding credentials never by itself authorises writes. |

```bash
uv run pytest -m "not live"                            # offline only
INKITT_USERNAME=... INKITT_PASSWORD=... uv run pytest  # + live reads
INKITT_ALLOW_WRITES=1 ... uv run pytest                # + real writes
uv run pytest -rs                                      # audit what skipped
```

Account setup the contract suite expects:

- A **confirmed email** — comment and wall writes answer 403
  (`AccountNotConfirmedError`) otherwise.
- The `INKITT_LIST_ID` list exists and is owned by the account, and
  `INKITT_ADDABLE_STORY_ID` is not in it when a run starts (the tests scrub, but a
  crashed run can leave state).
- Expect the suite to leave one wall post per writes run on the account's own wall —
  there is no delete route.

Run `pytest -rs` regularly. A silently-skipped contract test is the main failure mode of
a config-driven live suite.
