Metadata-Version: 2.4
Name: quran-foundation-api
Version: 0.6.0
Summary: Official Python SDK for Quran Foundation APIs.
Project-URL: Homepage, https://api-docs.quran.foundation/docs/sdk/python/
Project-URL: Documentation, https://api-docs.quran.foundation/docs/sdk/python/
Project-URL: Support, https://api-docs.quran.foundation/request-access/
Author-email: Quran Foundation <developers@quran.foundation>
License-Expression: MIT
License-File: LICENSE
Keywords: api,oauth2,quran,quran-foundation,sdk
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.9
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: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=6.0; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
Requires-Dist: uvicorn>=0.30; extra == 'fastapi'
Description-Content-Type: text/markdown

# quran-foundation-api

Official Python SDK for Quran Foundation APIs.

```bash
pip install quran-foundation-api
```

```python
from quran_foundation import QuranClient

client = QuranClient(client_id="YOUR_CLIENT_ID", access_token="ACCESS_TOKEN")
chapters = client.list_chapters()
print(chapters)
```

## Content and search

```python
from quran_foundation import QuranClient

client = QuranClient(client_id="YOUR_CLIENT_ID", access_token="ACCESS_TOKEN")

chapter = client.get_chapter(1)
verses = client.get_verses_by_range("1:1", "1:7", translations="131")
translations = client.list_translations()
results = client.search("mercy", mode="advanced", params={"size": 10})
```

### Content resource sync

Chapter recitations, Mushafs, word-by-word translations, and word-by-word
transliterations can be included in content sync and fetched as resource snapshots.
Once published, `quran_core:1` also provides canonical Uthmani verse text, Surah
metadata, and Juz/Hizb/Rub-el-Hizb boundaries; it is currently pending
content/licensing approval:

```python
changes = client.sync_resources(
    params={
        "bootstrap": True,
        "resources": (
            "chapter_recitations:159;mushafs:1;quran_core:1;word_by_word_translations:85;"
            "word_by_word_transliterations:60"
        ),
    }
)
chapter_recitation_snapshot = client.get_chapter_recitation_snapshot(159)
for record in chapter_recitation_snapshot["records"]:
    if record["record_type"] == "audio_segment":
        print(record["verse_key"], record["segments"])
snapshot = client.get_word_by_word_translation_snapshot(85)
transliteration_snapshot = client.get_word_by_word_transliteration_snapshot(60)
mushaf_snapshot = client.get_mushaf_snapshot(1)
core_snapshot = client.get_quran_core_snapshot()  # once quran_core:1 is public
for record in core_snapshot["records"]:
    if record["record_type"] == "verse":
        print(record["verse_key"], record["text_uthmani"])
```

Chapter-recitation snapshots use the audio recitation ID and contain
`chapter_audio_file` records plus associated `audio_segment` verse and word
timings. The SDK exports `ChapterAudioFileSnapshotRecord`,
`AudioSegmentSnapshotRecord`, and their `ChapterRecitationSnapshotRecord` union.
Bootstrap returns `RESOURCE_CREATE` entries with `snapshot_url`; it does not
return timing rows inline. Fetch and apply every referenced snapshot before
storing the final sync token. Apply incremental `audio_segment` row mutations
using `(resource_group, resource_id, record_type, record_key)` as the stable
local key, not `source_record_id` or `data`, because either may be `None` for a
delete. `RESOURCE_INVALIDATE` requires replacing the complete snapshot. Segment
timestamps and tuple boundaries are milliseconds from the start of the chapter
file; each tuple is `[one_based_word_index, start_ms, end_ms]`. Use
`duration_ms` for exact arithmetic; `duration` is the legacy whole-seconds
compatibility value. Mushaf snapshots contain the layout metadata, pages,
publicly distributable font assets, and words needed for offline use. The API also
exposes approved, shareable word-translation and word-transliteration resources. A
word-by-word transliteration snapshot record is available as the exported
`WordByWordTransliterationSnapshotRecord` type and contains `id`,
`resource_content_id`, `resource_id`, `word_id`, `language_id`, `language_name`,
`text`, and `updated_at`. Incremental row events for these resources use
`record_type="word_transliteration"`.

Quran core records are keyed by `(record_type, id)`. The SDK exports
`QuranCoreSnapshotRecord` and its chapter, verse, Juz, Hizb, and Rub-el-Hizb
variants. Mushaf-specific pages and glyphs remain in `mushafs:<id>`; keep the
same combined filter for subsequent incremental sync and refresh the snapshot
on `RESOURCE_INVALIDATE`. Do not interpret SDK support as permission to
redistribute text before the content/licensing approval and attribution check.

For endpoints that do not yet have a dedicated helper, use the service request helpers:

```python
client.content_request("/verses/by_page/1", params={"translations": "131"})
client.search_request("/api/v1/search", params={"query": "mercy", "mode": "quick"})
client.user_request("/bookmarks", params={"page": 1})
```

## Analytics Events

Analytics submission is server-side and requires a client-credentials access
token with the `analytics.events.write` scope. Keep the client secret and token
in server-side environment variables.

```python
import os
from datetime import datetime, timezone

from quran_foundation import AnalyticsEvent, QuranClient
from quran_foundation.oauth import client_credentials

token = client_credentials(
    client_id=os.environ["QURAN_CLIENT_ID"],
    client_secret=os.environ["QURAN_CLIENT_SECRET"],
    scope="analytics.events.write",
)
client = QuranClient(
    client_id=os.environ["QURAN_CLIENT_ID"],
    access_token=token["access_token"],
)

result = client.submit_analytics_events(
    [
        AnalyticsEvent(
            event_id="stable-event-id-1",
            name="quran.reader.verse_viewed",
            version=1,
            occurred_at=datetime.now(timezone.utc),
            user_id="QURAN_FOUNDATION_USER_ID",
            session_id="session-123",
            properties={"verse_key": "2:255", "surface": "reader"},
        ),
        AnalyticsEvent(
            event_id="stable-event-id-2",
            name="quran.app.started",
            version=1,
            occurred_at=datetime.now(timezone.utc),
            anonymous_id="anonymous-123",
        ),
    ]
)
```

A successful response accepts the complete batch. Retry a failed batch with
the same event IDs so downstream processing can identify duplicates. Use a
Quran Foundation OAuth user ID for `user_id`; omit it for guests or unknown
users.

## User APIs

Use signed-in User API helpers with a user access token. Keep the token server-side.

```python
profile = client.get_profile()
bookmarks = client.list_bookmarks()
client.create_bookmark({"verse_key": "2:255", "mushaf_id": 1})
client.update_preference({"key": "theme", "value": "dark"})
```

### App State

App State stores app-owned JSON documents for signed-in users. First read the
enabled data groups, then use a high-entropy idempotency key for every write.
The response ETag is opaque and quoted; store it unchanged for conditional
writes.

```python
config = client.get_app_state_configuration()

created = client.put_app_state_document(
    "settings",
    "theme",
    value={"mode": "dark"},
    schema_version=1,
    idempotency_key="01HZX4EXAMPLE8J4K7M2PQ9RST",
    if_none_match="*",
)

current = client.get_app_state_document("settings", "theme")
client.put_app_state_document(
    "settings",
    "theme",
    value={"mode": "light"},
    schema_version=1,
    idempotency_key="01HZX4EXAMPLE9Y5M8N3QR0STU",
    if_match=current.etag,
)
```

`if_match` and `if_none_match` are mutually exclusive; the client rejects calls
that provide both before sending an HTTP request. API failures raise
`QuranHttpError` with the existing `status_code` and parsed `payload` attributes,
plus copied read-only `headers` and the original `response_text`. Empty and
non-JSON error bodies therefore remain available for diagnostics without making
the response headers caller-mutable.

For offline startup, page through `bootstrap_app_state()` until `hasMore` is
false, then persist its `nextSyncToken`. Apply every page from
`get_app_state_changes()` and its next token atomically. If a token expires and
the API returns HTTP 410, preserve pending local writes, bootstrap again, drain
changes, replay pending writes with their original idempotency keys, then pull
once more.

For an application-managed offline workflow, use the synchronous transactional
reconciler with an explicit account identifier. `QuranClient` structurally
implements `AppStateTransport`; the included memory store is useful for
short-lived processes, while durable applications can implement `AppStateStore`
with the same atomic reducer contract.

```python
from quran_foundation import AppStateReconciler, MemoryAppStateStore

app_state = AppStateReconciler(
    client,
    MemoryAppStateStore(),
    account_id="your-stable-account-id",
)

queued = app_state.put_document(
    "settings",
    "theme",
    value={"mode": "dark"},
    schema_version=1,
)
assert queued.visible["settings/theme"].pending is True

current = app_state.reconcile()  # pull, replay the captured queue, then pull
other_account = app_state.switch_account(
    "another-stable-account-id",
    next_account_client,
)
```

Account switching replaces the local account boundary and transport atomically. Supply a separate
`QuranClient` whose access token belongs to the target account, and do not mutate one shared
client's token across accounts while reconciliation is in flight. An in-flight request retains the
client captured for its original account, and its late result cannot commit after the generation
changes.

Views, queued JSON bodies, and mutation snapshots are deeply immutable. The
reconciler stages bootstrap pages until the change drain completes, preserves
complete-replacement intent across bounded HTTP 412 rebases, recovers bounded
HTTP 410 token failures, and prevents late responses from a previous account
generation from committing. Account identity is always supplied by the caller;
the reconciler never parses access tokens.

## OAuth2 helpers

Use OAuth helpers on the server side. Never expose `client_secret`, access tokens, or refresh tokens to browser code.

```python
from quran_foundation.oauth import build_authorization_url, create_pkce_pair, exchange_code

verifier, challenge = create_pkce_pair()
authorize_url = build_authorization_url(
    client_id="YOUR_CLIENT_ID",
    redirect_uri="https://your-app.com/callback",
    scope="openid offline_access user bookmark",
    state="random-state",
    code_challenge=challenge,
)

tokens = exchange_code(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET",
    code="CODE_FROM_CALLBACK",
    code_verifier=verifier,
    redirect_uri="https://your-app.com/callback",
)
```

## Development

```bash
python -m pip install -e ".[dev]"
ruff check .
pytest
python -m build
twine check dist/*
```

## Contributing and releases

Public API changes require tests, documentation, a changelog entry, and a
version bump. Merging to `main` runs CI but does not publish the package. See
[`CONTRIBUTING.md`](CONTRIBUTING.md) for the versioning and release process.
