Metadata-Version: 2.5
Name: pynhentai
Version: 0.1.0
Summary: Typed sync + async Python client for the nHentai API v2
Project-URL: Homepage, https://gitlab.com/alb0402/pynhentai
Project-URL: Issues, https://gitlab.com/alb0402/pynhentai/-/issues
Author: alb0402
License: MIT
License-File: LICENSE
Keywords: api,async,client,httpx,wrapper
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: datamodel-code-generator>=0.25; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# pynhentai

A typed Python client for the [nHentai API v2](https://nhentai.net/api/v2/docs),
with both synchronous and asyncio interfaces.

Unofficial and unaffiliated. Adults only.

```python
from pynhentai import Client

with Client() as nh:
    gallery = nh.galleries.get(177013)
    print(gallery.title.pretty, gallery.num_pages)

    for item in nh.iter_search("language:english", sort="popular", max_pages=2):
        print(item.id, item.english_title)
```

The async interface mirrors it:

```python
from pynhentai import AsyncClient

async with AsyncClient() as nh:
    gallery = await nh.galleries.get(177013)
```

## Features

- Sync and async clients with identical method signatures.
- Pydantic v2 response models; the package passes `mypy --strict`.
- Automatic retries with exponential backoff, honouring `Retry-After`.
- A typed exception hierarchy mapped from HTTP status codes.
- Lazy pagination helpers for the paginated endpoints.
- Complete coverage of the API, verified against the vendored specification.

## Installation

```bash
pip install pynhentai
```

Requires Python 3.10 or newer. Runtime dependencies are `httpx` and `pydantic` v2.

For a development install:

```bash
pip install -e ".[dev]"
```

## Authentication

The API accepts two `Authorization` schemes, both exposed as credential objects
so that tokens can be rotated without rebuilding headers by hand.

```python
Client(api_key="...")  # Authorization: Key <api_key>
Client(access_token="...")  # Authorization: User <access_token>

with Client() as nh:
    nh.login("username", "password")  # stores the tokens on the client
    nh.refresh()  # rotates them in place
```

Anonymous clients can use the public read endpoints such as galleries, search
and tags. Favourites, comments, the blacklist and the `/user` endpoints require
credentials.

## Usage

### Galleries

```python
gallery = nh.galleries.get(177013)
gallery = nh.galleries.get(177013, include=["comments", "related"])

nh.galleries.random()
nh.galleries.popular()
nh.galleries.tagged(tag_id=12227, sort="popular")
nh.galleries.related(177013)
nh.galleries.comments(177013, page=1)
```

### Searching and pagination

`search()` returns a single page. `iter_search()` walks pages lazily and stops
at `max_pages`:

```python
page = nh.search("parody:touhou", sort="popular-week")
print(page.num_pages, len(page))

for item in nh.iter_search("artist:someone", max_pages=3):
    print(item.id, item.english_title)
```

### Image URLs

The API returns image paths rather than absolute URLs, since the CDN host is
drawn from a rotating pool. `urls` assembles them, and accepts a server list so
that a long-running process can refresh it from `nh.meta.cdn()`.

```python
from pynhentai import image_url

gallery = nh.galleries.get(177013)
for page in gallery.pages:
    print(image_url(gallery.media_id, page))
```

## Error handling

All exceptions derive from `NHentaiError`. Each `APIError` carries the
originating `httpx.Response` on `.response`.

```python
from pynhentai import NotFoundError, RateLimitError

try:
    nh.galleries.get(1)
except NotFoundError:
    ...
except RateLimitError as exc:
    time.sleep(exc.retry_after or 30)
```

| Exception | Raised on |
|---|---|
| `BadRequestError` | 400, 422 |
| `AuthenticationError` | 401 |
| `PermissionDeniedError` | 403 |
| `NotFoundError` | 404 |
| `RateLimitError` | 429; exposes `.retry_after` |
| `ServerError` | 5xx |
| `ChallengeRequiredError` | proof-of-work or captcha gating |
| `TransportError` | connection failures, exhausted retries |

`ChallengeRequiredError` reports that an endpoint requires a proof-of-work or
captcha solution. The library surfaces the challenge rather than solving it, and
leaves the handling to the caller.

Idempotent requests (`GET`, `HEAD`, `DELETE`) are retried on 429 and 5xx with
exponential backoff and full jitter. A `Retry-After` header takes precedence
over the computed delay. `POST` requests are never retried.

## Project layout

```
spec/openapi.json           vendored upstream OpenAPI document
scripts/refresh_spec.py     re-fetch it; reports added and removed endpoints
scripts/generate_models.py  regenerate models/generated.py from the spec
scripts/gen_async.py        regenerate resources/aio/ from resources/
src/pynhentai/
    client.py               Client, the blocking interface
    async_client.py         AsyncClient, the asyncio interface
    transport.py            retries, backoff, status to exception mapping
    _request.py             RequestSpec[T], a described call without a transport
    auth.py                 ApiKeyAuth and TokenAuth
    pagination.py           paginate and apaginate
    urls.py                 CDN URL assembly
    exceptions.py           the NHentaiError hierarchy
    models/core.py          models for the wrapped endpoints
    resources/              one module per API namespace
    resources/aio/          async counterparts, generated
tests/                      respx-backed; no network access
scripts/                    spec refresh, async codegen, coverage check
```

### How the sync and async interfaces stay in sync

Supporting both interfaces from a single hand-written method body would force
the return type to become `T | Awaitable[T]`, which types poorly at every call
site: sync callers must narrow before using a result, and async callers must
cast before awaiting. Maintaining two hand-written copies types correctly but
allows them to diverge.

Instead, the endpoint definitions live once in `resources/`, and the async
counterparts in `resources/aio/` are generated from them by
`scripts/gen_async.py`:

```python
# resources/galleries.py, the source of truth
def get(self, gallery_id: int, *, include=None) -> Gallery:
    return self._call(RequestSpec("GET", f"/galleries/{gallery_id}", ..., model=Gallery))


# resources/aio/galleries.py, generated
async def get(self, gallery_id: int, *, include=None) -> Gallery:
    return await self._call(RequestSpec("GET", f"/galleries/{gallery_id}", ..., model=Gallery))
```

`RequestSpec` is generic in its response type, so a `RequestSpec[Gallery]`
yields a `Gallery` on both interfaces, without unions or casts in user code.
CI runs `scripts/gen_async.py --check`, so a stale generated module fails the
pipeline. Adding an endpoint remains a single method in a single file.

### Why the specification is vendored

`spec/openapi.json` is the upstream machine-readable document, retrieved from
the public `/api/v2/openapi.json`. Committing it means that an upstream change
appears as a reviewable diff rather than as an unexplained runtime failure.

```bash
python scripts/refresh_spec.py     # reports "+ /api/v2/new-endpoint"
python scripts/generate_models.py
```

A scheduled CI job re-fetches the document and reports when the committed copy
has fallen behind.

## Endpoint coverage

All 105 operations in the specification are wrapped:

| Namespace | Endpoints |
|---|---|
| `nh.meta` | `/`, `/config`, `/cdn`, `/pow`, `/captcha` |
| `nh.galleries` | list, get, random, popular, tagged, related, comments, favourite, download, tag edits, comment flagging |
| `nh.search` | `/search` |
| `nh.tags` | by type, by slug, by ids, autocomplete |
| `nh.user` | profile, update, avatar, API keys, account deletion |
| `nh.favorites` | list, random |
| `nh.blacklist` | get, ids, update |
| `nh.sessions` | login, register, refresh, logout, password reset, session listing |
| `nh.gts` | gallery tag suggestions: list, create, vote, withdraw, backlog, new tags |
| `nh.taxonomy` | tag vocabulary suggestions: list, create, revise, vote, discuss, stats |
| `nh.moderation` | suggestion and taxonomy resolution, users, galleries, comments, flags, edits, bulk actions, API keys, spam configuration |
| `nh.zones` | advertising zones and popunder inventory |

`scripts/check_coverage.py` compares the resource modules against the vendored
specification and reports anything unwrapped; CI runs it with `--check`.

Two notes on the namespaces that require elevated permissions:

- Everything under `nh.moderation` needs a staff account. Ordinary accounts
  receive `PermissionDeniedError`.
- The endpoints that create content (suggestions, taxonomy proposals,
  registration, password reset) are gated on a proof-of-work challenge, and
  sometimes a captcha. Those methods take `pow_challenge` and `pow_nonce` as
  required arguments. Fetch a challenge with `nh.meta.pow_challenge()`; this
  library surfaces challenges but does not solve them.

## Development

```bash
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy
python scripts/gen_async.py --check
python scripts/check_coverage.py --check
```

Tests use `respx` to intercept httpx at the transport layer, so the client,
retry loop and model parsing all execute normally against canned responses. No
test performs network access.

## Contributing

Bug reports and merge requests are welcome. See [CONTRIBUTING.md](https://gitlab.com/alb0402/pynhentai/-/blob/main/CONTRIBUTING.md)
for the development workflow and conventions.

## License

MIT. See [LICENSE](https://gitlab.com/alb0402/pynhentai/-/blob/main/LICENSE).
