Metadata-Version: 2.4
Name: brio-sdk
Version: 0.1.0
Summary: Client SDK (sync + async) des services IA BRIO — soumission de tâches, polling, fichiers.
Project-URL: Repository, https://github.com/IA-Generative/async-api
Project-URL: Documentation, https://github.com/IA-Generative/async-api/tree/main/clients/python/brio-sdk
License: MIT
Keywords: async-api,brio,minint,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic>=2.10
Description-Content-Type: text/markdown

# brio-sdk

Client Python des services IA **BRIO** : soumettre une tâche, attendre son résultat, envoyer et
récupérer des fichiers — sans réécrire l'auth, le polling ni le mapping d'erreurs.

Deux clients à **surface identique** : `BrioClient` (synchrone, pour les scripts) et
`AsyncBrioClient` (asynchrone, pour un service FastAPI ou un worker). Mêmes méthodes, mêmes
signatures, mêmes types de retour.

```bash
pip install brio-sdk
```

Python ≥ 3.11.

## Configuration

Les credentials viennent des arguments du constructeur, ou de l'environnement :

```bash
export BRIO_BASE_URL="https://async-api.sdid-app.cpin.numerique-interieur.com"
export BRIO_CLIENT_ID="mon_client"
export BRIO_CLIENT_SECRET="..."
```

Un réglage manquant lève `BrioConfigError` **au constructeur** — pas une 401 au milieu d'un
traitement. Pour vérifier que les identifiants sont bons côté serveur :

```python
from brio_sdk import BrioClient

with BrioClient() as client:
    me = client.whoami()
    print(me.client_id, [a.service for a in me.authorizations])
```

## Quickstart (synchrone)

```python
from brio_sdk import BrioClient, Service
from brio_sdk.services import SplitDocumentBody, SplitDocumentResult

with BrioClient() as client:
    upload = client.upload_file("dossier.pdf")  # bascule API/S3 automatique
    pending = client.submit_task(
        Service.SPLIT_DOCUMENT,
        SplitDocumentBody(file_id=upload.file_id),  # ou simplement {"file_id": ...}
    )
    success = client.wait_for_result(Service.SPLIT_DOCUMENT, pending.data.task_id)

    split = SplitDocumentResult.model_validate(success.result)  # typage opt-in du résultat
    for document in split.documents:
        client.download_file(document.file_id, f"{document.page_start}.pdf")
```

## Quickstart (asynchrone)

```python
import asyncio

from brio_sdk import AsyncBrioClient, Service
from brio_sdk.services import SplitDocumentBody, SplitDocumentResult


async def main() -> None:
    async with AsyncBrioClient() as client:
        upload = await client.upload_file("dossier.pdf")
        pending = await client.submit_task(
            Service.SPLIT_DOCUMENT,
            SplitDocumentBody(file_id=upload.file_id),
        )
        success = await client.wait_for_result(Service.SPLIT_DOCUMENT, pending.data.task_id)
        print(SplitDocumentResult.model_validate(success.result).documents)


asyncio.run(main())
```

## Ce que le SDK prend en charge pour vous

| Sujet | Comportement |
|---|---|
| Auth | HTTP Basic injectée à chaque appel, jamais loguée |
| Polling | `wait_for_result` : intervalle doublé jusqu'à 30 s, délai max réglable, retour immédiat si la tâche est déjà finie |
| Réessais | 429 et 502/503/504 et erreurs de transport : backoff exponentiel + jitter, plafonné à 30 s, `Retry-After` prioritaire s'il est émis. `RetryConfig(max_attempts=1)` désactive tout |
| Upload | Bascule automatique : transit par l'API sous 25 Mo, URL pré-signée S3 au-delà (jusqu'à 500 Mo). Contenu streamé, pas de chargement en mémoire |
| Sécurité S3 | Les requêtes vers S3 partent sur un client HTTP dédié : l'en-tête `Authorization` de l'API ne fuite jamais chez le fournisseur de stockage |
| Typage | Modèles de réponse typés, union discriminée sur `data.status`, bodies typés par service |

## Erreurs : quoi attraper, quoi en faire

Toutes héritent de `BrioError`.

| Exception | Cause serveur | Action attendue |
|---|---|---|
| `BrioConfigError` | Configuration locale incomplète | Corriger les arguments ou les variables d'environnement |
| `AuthenticationError` | `AUTHENTICATION_REQUIRED` (401) | Vérifier `client_id` / `client_secret` |
| `ServiceForbidden` | `SERVICE_FORBIDDEN` (403) | Demander l'autorisation du client sur ce service |
| `ServiceNotFound` | `SERVICE_NOT_FOUND` (404) | Corriger le nom du service (voir l'enum `Service`) |
| `TaskNotFound` / `FileNotFound` | `TASK_NOT_FOUND` / `FILE_NOT_FOUND` (404) | Identifiant inconnu, ou appartenant à un autre client |
| `BodyValidationError` | `BODY_SCHEMA_INVALID`, `REQUEST_VALIDATION_ERROR`, `MALFORMED_REQUEST` | Lire `error.issues` : chaque entrée donne le champ fautif |
| `MissingMultipartField` | `MISSING_MULTIPART_FIELD` (422) | Champ absent du form-data d'upload |
| `TextTooLarge` | `TEXT_TOO_LARGE` (413) | Découper le contenu en entrée |
| `QuotaExceeded` | `SERVICE_QUOTA_EXCEEDED`, `CLIENT_SERVICE_QUOTA_EXCEEDED` (429) | Réessayer plus tard (le SDK a déjà réessayé) |
| `ServiceUnavailable` | `DEPENDENCIES_NOT_READY` (503) | Dépendance serveur indisponible, réessayer plus tard |
| `TaskFailed` | La tâche s'est terminée en échec | `error.message` porte le motif renvoyé par le service |
| `TaskTimeoutError` | Délai d'attente dépassé | La tâche **continue** côté serveur : relancer `wait_for_result` avec le même `task_id` |
| `BrioServerError` | Erreur non mappée, ou réponse hors contrat | Diagnostic : `error.response_payload` contient le corps brut |
| `BrioTransportError` | Aucune réponse (DNS, TCP, TLS, timeout) | Vérifier réseau et `base_url` |

```python
from brio_sdk import BodyValidationError, QuotaExceeded

try:
    client.submit_task(Service.CLASSIFY_DOCUMENT, {"file_id": file_id})
except BodyValidationError as error:
    for issue in error.issues:
        print(issue.loc, issue.msg)
except QuotaExceeded:
    print("quota atteint, on réessaiera plus tard")
```

`repr()` d'une exception n'expose jamais le corps de réponse ni un secret : elle peut être loguée
telle quelle.

## Services et bodies typés

`Service` est un `StrEnum` **ouvert** : une chaîne libre reste acceptée, parce que le catalogue est
piloté par la configuration serveur — un service plus récent que le SDK reste appelable.

| Service | Body typé | Résultat typé |
|---|---|---|
| `extract-text` | `ExtractTextBody` | `ExtractTextResult` |
| `classify-document` | `ClassifyDocumentBody` | `ClassifyDocumentResult` |
| `split-document` | `SplitDocumentBody` | `SplitDocumentResult` |
| `index-document` | `IndexDocumentBody` | `IndexDocumentResult` |
| `generation-render` | `GenerationRenderBody` / `GenerationRenderInlineBody` | `GenerationRenderResult` / `InlineRenderResult` |
| `extract-entities` | dict (méta-DSL récursif, non typé) | dict |

Les bodies sont en `extra="forbid"` : un champ mal orthographié est rejeté **localement**, avant
l'appel réseau. Les modèles de résultat sont en `extra="ignore"` et s'appliquent à la demande
(`Model.model_validate(success.result)`), ce qui isole le consommateur des évolutions de schéma
côté workers.

## Injection de dépendances

```python
import httpx

from brio_sdk import BrioClient, RetryConfig

client = BrioClient(
    retry=RetryConfig(max_attempts=5, backoff_cap_seconds=10.0),
    http_client=httpx.Client(proxy="http://proxy.interne:3128"),  # proxy, mTLS, timeouts maison
    s3_http_client=httpx.Client(),  # client distinct pour S3
)
```

`clock` est également injectable (protocole `Clock` / `AsyncClock`) : les tests couvrent timeouts et
backoff sans attendre réellement.

## Compatibilité

Le SDK est ancré sur l'API **`/v1`** de l'async-api, pas sur un numéro de version serveur. Les
modèles de réponse ignorent les champs inconnus : un ajout côté serveur ne casse pas un
consommateur déjà déployé.

## Développement

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

Aucun test ne touche le réseau (`httpx.MockTransport`) ni ne dort (horloge factice).
