Metadata-Version: 2.4
Name: website-api-sdk
Version: 0.7.7
Summary: Python SDK for Daniel's website/API server
Author: Daniel Dolezal
License-Expression: MIT
Keywords: api,sdk,website
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=46.0.0
Requires-Dist: msgpack>=1.1.0
Requires-Dist: requests>=2.32.0
Requires-Dist: websockets>=16.0

# website-api-sdk
Python SDK for Daniel's website/API server.

## Install
```bash
uv add website-api-sdk
```

For local development:

```bash
uv sync
uv run ruff check .
uv run pytest
```

## Basic usage
```python
from website_api_sdk import DEFAULT_API_VERSION, WebsiteApiClient

client = WebsiteApiClient(
  urls=['https://api.example'],
  token={
    'token_type': 'Bearer',
    'refresh_token': '...',
  },
  table_name='Archive',
)

print(client.public.get_api_versions())
print(client.submissions.get_all_urls())
```

By default the SDK sends no `X-API-Version` header, so the API can use the authenticated user's pinned version.

Force a specific API version for a client/request set:

```python
client = WebsiteApiClient(
  urls=['https://api.example'],
  token={'access_token': '...'},
  api_version=DEFAULT_API_VERSION,
)
```

Update the user's pinned API version:

```python
client.set_api_version_pin(DEFAULT_API_VERSION)
```

Inspect the API version the server used for the last response:

```python
print(client.get_last_api_version_used())
```

## Structure
Endpoint groups are exposed as typed resource objects:
- `client.public`: versions, server key, gif, music, cookie, furry interactive story
- `client.health`: health/convex endpoint
- `client.submissions`: FurAffinity submission archive tables, entries, status/date queries, batch upsert/delete
- `client.furaffinity`: FurAffinity favs/watchers and submission absorber state
- `client.n8n`: YouTube channel workflow endpoints
- `client.printer`: printer REST endpoints

Transport/auth helpers are inherited by the client itself, so direct raw calls are available through `client.request(...)`.

## Cache and auth
Only auth is stored as JSON. Queued batch/delete work is stored in SQLite when durable queuing is enabled.

```python
client = WebsiteApiClient(
  urls='https://api.example',
  token={'refresh_token': '...'},
  table_name='Archive',
  cache_path={
    'auth': '.cache/website_api_sdk/auth.json',
    'queue': '.cache/website_api_sdk/queue.sqlite3',
  },
)
```

With this setup:
- `auth.json` stores only token/auth data
- `queue.sqlite3` stores queued batch/delete entries
- no root/base batch JSON is created
- no JSON backup/spool files are created
- no worker ID/PID config is needed
- auth JSON is written atomically with `0o600` permissions and a lock directory
- SQLite handles concurrent queue claims between multiple program runs

Batch sends claim pending SQLite rows in one transaction by changing them to `inflight`. Other instances cannot claim the same rows while they are inflight. If sending fails, claimed rows are restored to `pending`. On success, claimed rows are deleted.

If you only want shared auth and do not need durable queued batch/delete entries, omit `queue`:

```python
client = WebsiteApiClient(
  urls='https://api.example',
  token={'refresh_token': '...'},
  table_name='Archive',
  cache_path={
    'auth': '.cache/website_api_sdk/auth.json',
  },
)
```

In auth-only mode, tokens are still persisted/shared, but queued batch/delete entries stay in memory.

You can inspect cache content safely. Token data is shown first, followed by SQLite queue entries grouped by `pending` / `inflight`, action, and table/path. Token-like values are redacted by default:

```python
print(client.show_cache_content(timezone_name='Europe/Berlin'))
cache = client.read_cache_content(timezone_name='Europe/Berlin')
```

`created_at` and `expires_at` are converted to `timezone_name` when provided. Pass `redact_auth=False` only if you really need the full token values.

## Errors
The SDK raises typed exceptions from `website_api_sdk`:

- `AuthenticationError` for 400/401/403 responses
- `NotFoundError` for 404 responses
- `RateLimitError` for 429 responses
- `ApiError` for other non-200 responses
- `NoAvailableApiUrlError` when URL discovery cannot find a healthy API URL

`Retry-After` headers on 429/503/504 responses are respected automatically. By default the SDK sleeps for the advertised delay, capped at 120 seconds, and retries up to 5 times. Configure this with `retry_after_max_retries`, `retry_after_max_sleep`, or inject `retry_after_sleep` in tests. If the final response still fails, `ApiError.retry_after` and `ApiError.retry_after_raw` expose the parsed/raw header values.

## Examples

### Current API version
```bash
WEBSITE_API_URL=https://api.example uv run python examples/get_current_api_version.py
```

The example calls the public `/api/versions` endpoint and prints the `X-API-Version-Used` header captured by `client.get_last_api_version_used()`.

### Submission archive
```python
client.select_table('Archive')

client.submissions.upsert_submissions({'url': 'https://www.furaffinity.net/view/123/'})
client.submissions.append({'url': 'https://www.furaffinity.net/view/456/'})
client.submissions.send_batch()

not_downloaded = client.submissions.get_all_not_downloaded_items()
```

### Submission absorber
```python
state = client.furaffinity.get_submission_absorber()
client.furaffinity.save_submission_absorber({
  'ignoredSubmissions': state.get('ignoredSubmissions', []),
  'deleteSubmissions': [],
  'ignoredSeriesByArtist': state.get('ignoredSeriesByArtist', []),
  'url': state.get('url'),
  'page_submission_size': 128,
})
```

### n8n
```python
client.n8n.upsert_youtube_channels({'channel_name': 'Example Channel'})
feed = client.n8n.get_youtube_channel_feed(is_after='2026-01-01', convert_to_time_zone='Europe/Berlin')
```

### Printer
```python
client.printer.upsert_printer('kitchen', model='epson', status=True)
client.printer.add_printer_jobs('kitchen', {'type': 'text', 'content': {'text': 'Hello'}})
client.printer.add_printer_cut_job('kitchen')
```

Create the matching printer websocket client:

```python
client = WebsiteApiClient(
  urls='https://api.example',
  token={'refresh_token': '...'},
)

ws_client = client.printer.create_printer_websocket_client('kitchen')
await ws_client.start()
```

Subclass `AsyncWebSocketClient` if you need custom `on_connect`, `on_message` or `on_disconnect` handling.
