brio-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brio_sdk/__init__.py +107 -0
- brio_sdk/_clock.py +59 -0
- brio_sdk/_config.py +98 -0
- brio_sdk/_constants.py +73 -0
- brio_sdk/_http.py +145 -0
- brio_sdk/_payloads.py +58 -0
- brio_sdk/_upload.py +91 -0
- brio_sdk/async_client.py +303 -0
- brio_sdk/client.py +303 -0
- brio_sdk/exceptions.py +223 -0
- brio_sdk/models.py +165 -0
- brio_sdk/py.typed +0 -0
- brio_sdk/services.py +161 -0
- brio_sdk-0.1.0.dist-info/METADATA +195 -0
- brio_sdk-0.1.0.dist-info/RECORD +16 -0
- brio_sdk-0.1.0.dist-info/WHEEL +4 -0
brio_sdk/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""brio-sdk — client (sync + async) des services IA BRIO.
|
|
2
|
+
|
|
3
|
+
from brio_sdk import BrioClient, Service
|
|
4
|
+
from brio_sdk.services import SplitDocumentBody, SplitDocumentResult
|
|
5
|
+
|
|
6
|
+
with BrioClient() as client: # credentials depuis l'environnement
|
|
7
|
+
upload = client.upload_file("dossier.pdf")
|
|
8
|
+
pending = client.submit_task(Service.SPLIT_DOCUMENT, SplitDocumentBody(file_id=upload.file_id))
|
|
9
|
+
success = client.wait_for_result(Service.SPLIT_DOCUMENT, pending.data.task_id)
|
|
10
|
+
split = SplitDocumentResult.model_validate(success.result)
|
|
11
|
+
|
|
12
|
+
Compatibilité ancrée sur l'API `/v1` de l'async-api, pas sur un numéro de version serveur.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from brio_sdk._clock import AsyncClock, AsyncSystemClock, Clock, SystemClock
|
|
18
|
+
from brio_sdk._config import ClientConfig, RetryConfig
|
|
19
|
+
from brio_sdk._constants import SDK_VERSION
|
|
20
|
+
from brio_sdk.async_client import AsyncBrioClient
|
|
21
|
+
from brio_sdk.client import BrioClient
|
|
22
|
+
from brio_sdk.exceptions import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
BodyValidationError,
|
|
25
|
+
BrioConfigError,
|
|
26
|
+
BrioError,
|
|
27
|
+
BrioServerError,
|
|
28
|
+
BrioTransportError,
|
|
29
|
+
FileNotFound,
|
|
30
|
+
Issue,
|
|
31
|
+
MissingMultipartField,
|
|
32
|
+
NotImplementedByServer,
|
|
33
|
+
QuotaExceeded,
|
|
34
|
+
ServerErrorCode,
|
|
35
|
+
ServiceForbidden,
|
|
36
|
+
ServiceNotFound,
|
|
37
|
+
ServiceUnavailable,
|
|
38
|
+
TaskFailed,
|
|
39
|
+
TaskNotFound,
|
|
40
|
+
TaskTimeoutError,
|
|
41
|
+
TextTooLarge,
|
|
42
|
+
)
|
|
43
|
+
from brio_sdk.models import (
|
|
44
|
+
Callback,
|
|
45
|
+
CallbackPayload,
|
|
46
|
+
MeResponse,
|
|
47
|
+
PresignedDownloadResponse,
|
|
48
|
+
PresignedUploadResponse,
|
|
49
|
+
ServiceAuthorization,
|
|
50
|
+
StorageUploadResponse,
|
|
51
|
+
TaskData,
|
|
52
|
+
TaskDataFailed,
|
|
53
|
+
TaskDataPending,
|
|
54
|
+
TaskDataProgress,
|
|
55
|
+
TaskDataSuccess,
|
|
56
|
+
TaskResponse,
|
|
57
|
+
TaskStatus,
|
|
58
|
+
)
|
|
59
|
+
from brio_sdk.services import Service
|
|
60
|
+
|
|
61
|
+
__version__ = SDK_VERSION
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"AsyncBrioClient",
|
|
65
|
+
"AsyncClock",
|
|
66
|
+
"AsyncSystemClock",
|
|
67
|
+
"AuthenticationError",
|
|
68
|
+
"BodyValidationError",
|
|
69
|
+
"BrioClient",
|
|
70
|
+
"BrioConfigError",
|
|
71
|
+
"BrioError",
|
|
72
|
+
"BrioServerError",
|
|
73
|
+
"BrioTransportError",
|
|
74
|
+
"Callback",
|
|
75
|
+
"CallbackPayload",
|
|
76
|
+
"ClientConfig",
|
|
77
|
+
"Clock",
|
|
78
|
+
"FileNotFound",
|
|
79
|
+
"Issue",
|
|
80
|
+
"MeResponse",
|
|
81
|
+
"MissingMultipartField",
|
|
82
|
+
"NotImplementedByServer",
|
|
83
|
+
"PresignedDownloadResponse",
|
|
84
|
+
"PresignedUploadResponse",
|
|
85
|
+
"QuotaExceeded",
|
|
86
|
+
"RetryConfig",
|
|
87
|
+
"ServerErrorCode",
|
|
88
|
+
"Service",
|
|
89
|
+
"ServiceAuthorization",
|
|
90
|
+
"ServiceForbidden",
|
|
91
|
+
"ServiceNotFound",
|
|
92
|
+
"ServiceUnavailable",
|
|
93
|
+
"StorageUploadResponse",
|
|
94
|
+
"SystemClock",
|
|
95
|
+
"TaskData",
|
|
96
|
+
"TaskDataFailed",
|
|
97
|
+
"TaskDataPending",
|
|
98
|
+
"TaskDataProgress",
|
|
99
|
+
"TaskDataSuccess",
|
|
100
|
+
"TaskFailed",
|
|
101
|
+
"TaskNotFound",
|
|
102
|
+
"TaskResponse",
|
|
103
|
+
"TaskStatus",
|
|
104
|
+
"TaskTimeoutError",
|
|
105
|
+
"TextTooLarge",
|
|
106
|
+
"__version__",
|
|
107
|
+
]
|
brio_sdk/_clock.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Horloges injectables (DI par `Protocol`).
|
|
2
|
+
|
|
3
|
+
Le temps est une dépendance comme une autre : en test on injecte une horloge qui
|
|
4
|
+
n'attend pas mais avance le temps, ce qui permet de couvrir les timeouts et le backoff
|
|
5
|
+
sans dormir. `monotonic` (et pas `time()`) : le temps écoulé reste juste même si
|
|
6
|
+
l'heure système change pendant l'attente.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class Clock(Protocol):
|
|
18
|
+
"""Horloge synchrone."""
|
|
19
|
+
|
|
20
|
+
def monotonic(self) -> float: ...
|
|
21
|
+
|
|
22
|
+
def sleep(self, seconds: float) -> None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class AsyncClock(Protocol):
|
|
27
|
+
"""Horloge asynchrone (même surface, `sleep` awaitable)."""
|
|
28
|
+
|
|
29
|
+
def monotonic(self) -> float: ...
|
|
30
|
+
|
|
31
|
+
async def sleep(self, seconds: float) -> None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SystemClock:
|
|
35
|
+
"""Horloge réelle synchrone."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ()
|
|
38
|
+
|
|
39
|
+
def monotonic(self) -> float:
|
|
40
|
+
return time.monotonic()
|
|
41
|
+
|
|
42
|
+
def sleep(self, seconds: float) -> None:
|
|
43
|
+
time.sleep(seconds)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncSystemClock:
|
|
47
|
+
"""Horloge réelle asynchrone.
|
|
48
|
+
|
|
49
|
+
`asyncio.sleep` est un point d'annulation : une `CancelledError` se propage
|
|
50
|
+
naturellement, sans laisser de tâche fantôme.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
__slots__ = ()
|
|
54
|
+
|
|
55
|
+
def monotonic(self) -> float:
|
|
56
|
+
return time.monotonic()
|
|
57
|
+
|
|
58
|
+
async def sleep(self, seconds: float) -> None:
|
|
59
|
+
await asyncio.sleep(seconds)
|
brio_sdk/_config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Résolution de la configuration client : arguments explicites > variables d'environnement.
|
|
2
|
+
|
|
3
|
+
Le manque de credentials est détecté **au constructeur** (`BrioConfigError`) et non au
|
|
4
|
+
premier appel : un script mal configuré échoue tout de suite, pas après un upload.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from brio_sdk._constants import (
|
|
13
|
+
DEFAULT_BACKOFF_BASE_SECONDS,
|
|
14
|
+
DEFAULT_BACKOFF_CAP_SECONDS,
|
|
15
|
+
DEFAULT_BACKOFF_JITTER_RATIO,
|
|
16
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
18
|
+
ENV_BASE_URL,
|
|
19
|
+
ENV_CLIENT_ID,
|
|
20
|
+
ENV_CLIENT_SECRET,
|
|
21
|
+
)
|
|
22
|
+
from brio_sdk.exceptions import BrioConfigError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class RetryConfig:
|
|
27
|
+
"""Politique de réessai. `max_attempts=1` désactive le retry."""
|
|
28
|
+
|
|
29
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
|
30
|
+
backoff_base_seconds: float = DEFAULT_BACKOFF_BASE_SECONDS
|
|
31
|
+
backoff_cap_seconds: float = DEFAULT_BACKOFF_CAP_SECONDS
|
|
32
|
+
jitter_ratio: float = DEFAULT_BACKOFF_JITTER_RATIO
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if self.max_attempts < 1:
|
|
36
|
+
message = "max_attempts doit valoir au moins 1 (1 = aucun réessai)."
|
|
37
|
+
raise BrioConfigError(message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ClientConfig:
|
|
42
|
+
"""Configuration résolue d'un client."""
|
|
43
|
+
|
|
44
|
+
base_url: str
|
|
45
|
+
client_id: str
|
|
46
|
+
client_secret: str
|
|
47
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
48
|
+
retry: RetryConfig = RetryConfig()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _first_present(explicit: str | None, env_var: str) -> str | None:
|
|
52
|
+
if explicit is not None and explicit != "":
|
|
53
|
+
return explicit
|
|
54
|
+
from_env = os.environ.get(env_var)
|
|
55
|
+
return from_env if from_env else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_config(
|
|
59
|
+
base_url: str | None = None,
|
|
60
|
+
client_id: str | None = None,
|
|
61
|
+
client_secret: str | None = None,
|
|
62
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
63
|
+
retry: RetryConfig | None = None,
|
|
64
|
+
) -> ClientConfig:
|
|
65
|
+
"""Construit la config depuis les arguments, en retombant sur l'environnement.
|
|
66
|
+
|
|
67
|
+
Lève `BrioConfigError` en listant **tous** les réglages manquants d'un coup, pour
|
|
68
|
+
éviter de corriger une variable d'env à la fois.
|
|
69
|
+
"""
|
|
70
|
+
resolved_base_url = _first_present(base_url, ENV_BASE_URL)
|
|
71
|
+
resolved_client_id = _first_present(client_id, ENV_CLIENT_ID)
|
|
72
|
+
resolved_client_secret = _first_present(client_secret, ENV_CLIENT_SECRET)
|
|
73
|
+
|
|
74
|
+
missing = [
|
|
75
|
+
env_var
|
|
76
|
+
for value, env_var in (
|
|
77
|
+
(resolved_base_url, ENV_BASE_URL),
|
|
78
|
+
(resolved_client_id, ENV_CLIENT_ID),
|
|
79
|
+
(resolved_client_secret, ENV_CLIENT_SECRET),
|
|
80
|
+
)
|
|
81
|
+
if value is None
|
|
82
|
+
]
|
|
83
|
+
if missing:
|
|
84
|
+
message = (
|
|
85
|
+
"Configuration incomplète : "
|
|
86
|
+
+ ", ".join(missing)
|
|
87
|
+
+ " manquant(s). Passez-les au constructeur ou définissez ces variables d'environnement."
|
|
88
|
+
)
|
|
89
|
+
raise BrioConfigError(message)
|
|
90
|
+
|
|
91
|
+
# Les valeurs sont non-None ici (garanti par le contrôle ci-dessus).
|
|
92
|
+
return ClientConfig(
|
|
93
|
+
base_url=str(resolved_base_url).rstrip("/"),
|
|
94
|
+
client_id=str(resolved_client_id),
|
|
95
|
+
client_secret=str(resolved_client_secret),
|
|
96
|
+
timeout_seconds=timeout_seconds,
|
|
97
|
+
retry=retry if retry is not None else RetryConfig(),
|
|
98
|
+
)
|
brio_sdk/_constants.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Constantes internes — aucun magic number/string ailleurs dans le SDK.
|
|
2
|
+
|
|
3
|
+
Les valeurs marquées « vérifié » ont été relues dans le code serveur : elles doivent
|
|
4
|
+
être re-vérifiées si `api/` bouge (la détection de drift de #436 sert à ça).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
# --- Version & en-têtes ---
|
|
13
|
+
# Lue depuis les métadonnées du package installé : une seule source de vérité, celle que
|
|
14
|
+
# `release-please` met à jour dans `pyproject.toml`. Aucun risque de version en dur périmée.
|
|
15
|
+
_LOCAL_VERSION_FALLBACK: Final[str] = "0.0.0+local"
|
|
16
|
+
try:
|
|
17
|
+
SDK_VERSION: Final[str] = version("brio-sdk")
|
|
18
|
+
except PackageNotFoundError: # pragma: no cover — package non installé (exécution depuis le dépôt)
|
|
19
|
+
SDK_VERSION = _LOCAL_VERSION_FALLBACK
|
|
20
|
+
USER_AGENT: Final[str] = f"brio-sdk/{SDK_VERSION}"
|
|
21
|
+
SDK_VERSION_HEADER: Final[str] = "X-SDK-Version"
|
|
22
|
+
|
|
23
|
+
# --- Préfixes serveur (vérifié : api/main.py, RoutePrefix) ---
|
|
24
|
+
# Les tâches et /me sont sous /v1 ; le stockage est monté sous /storage, PAS /v1/storage.
|
|
25
|
+
API_V1_PREFIX: Final[str] = "/v1"
|
|
26
|
+
STORAGE_PREFIX: Final[str] = "/storage"
|
|
27
|
+
|
|
28
|
+
PATH_ME: Final[str] = f"{API_V1_PREFIX}/me"
|
|
29
|
+
PATH_TASKS_TEMPLATE: Final[str] = f"{API_V1_PREFIX}/services/{{service}}/tasks"
|
|
30
|
+
PATH_TASK_TEMPLATE: Final[str] = f"{API_V1_PREFIX}/services/{{service}}/tasks/{{task_id}}"
|
|
31
|
+
PATH_UPLOAD: Final[str] = f"{STORAGE_PREFIX}/upload"
|
|
32
|
+
PATH_PRESIGNED_UPLOAD: Final[str] = f"{STORAGE_PREFIX}/presigned-upload"
|
|
33
|
+
PATH_PRESIGNED_DOWNLOAD: Final[str] = f"{STORAGE_PREFIX}/presigned-download"
|
|
34
|
+
|
|
35
|
+
# Nom du champ multipart attendu par POST /storage/upload (vérifié : api/routes/storage.py).
|
|
36
|
+
UPLOAD_FIELD_NAME: Final[str] = "file"
|
|
37
|
+
|
|
38
|
+
# --- Variables d'environnement ---
|
|
39
|
+
ENV_BASE_URL: Final[str] = "BRIO_BASE_URL"
|
|
40
|
+
ENV_CLIENT_ID: Final[str] = "BRIO_CLIENT_ID"
|
|
41
|
+
ENV_CLIENT_SECRET: Final[str] = "BRIO_CLIENT_SECRET" # noqa: S105 — nom de variable, pas un secret
|
|
42
|
+
|
|
43
|
+
# --- Timeouts & retry ---
|
|
44
|
+
DEFAULT_TIMEOUT_SECONDS: Final[float] = 30.0
|
|
45
|
+
DEFAULT_MAX_ATTEMPTS: Final[int] = 3
|
|
46
|
+
DEFAULT_BACKOFF_BASE_SECONDS: Final[float] = 0.5
|
|
47
|
+
DEFAULT_BACKOFF_CAP_SECONDS: Final[float] = 30.0
|
|
48
|
+
DEFAULT_BACKOFF_JITTER_RATIO: Final[float] = 0.25
|
|
49
|
+
|
|
50
|
+
# --- Polling (wait_for_result) ---
|
|
51
|
+
DEFAULT_POLL_TIMEOUT_SECONDS: Final[float] = 300.0
|
|
52
|
+
DEFAULT_POLL_INITIAL_INTERVAL_SECONDS: Final[float] = 1.0
|
|
53
|
+
DEFAULT_POLL_MAX_INTERVAL_SECONDS: Final[float] = 30.0
|
|
54
|
+
|
|
55
|
+
# --- Upload : bascule direct -> presigned ---
|
|
56
|
+
# Vérifié : API_UPLOAD_MAX_SIZE_MB = 25 (transit API, aligné sur proxy-body-size de l'ingress)
|
|
57
|
+
# et S3_PRESIGNED_UPLOAD_MAX_SIZE_MB = 500 (plafond content-length-range de la policy).
|
|
58
|
+
_BYTES_PER_MB: Final[int] = 1024 * 1024
|
|
59
|
+
DIRECT_UPLOAD_MAX_SIZE_BYTES: Final[int] = 25 * _BYTES_PER_MB
|
|
60
|
+
PRESIGNED_UPLOAD_MAX_SIZE_BYTES: Final[int] = 500 * _BYTES_PER_MB
|
|
61
|
+
|
|
62
|
+
# Taille des chunks pour l'écriture d'un fichier téléchargé.
|
|
63
|
+
DOWNLOAD_CHUNK_SIZE_BYTES: Final[int] = 64 * 1024
|
|
64
|
+
|
|
65
|
+
DEFAULT_MIME_TYPE: Final[str] = "application/octet-stream"
|
|
66
|
+
|
|
67
|
+
# --- Statuts HTTP ---
|
|
68
|
+
HTTP_TOO_MANY_REQUESTS: Final[int] = 429
|
|
69
|
+
RETRYABLE_SERVER_STATUS: Final[frozenset[int]] = frozenset({502, 503, 504})
|
|
70
|
+
|
|
71
|
+
# En-tête standard de réessai (RFC 7231). Le serveur ne l'émet pas aujourd'hui : on le lit
|
|
72
|
+
# quand même, il primera sur le backoff dès qu'il apparaîtra.
|
|
73
|
+
RETRY_AFTER_HEADER: Final[str] = "Retry-After"
|
brio_sdk/_http.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Cœur pur : décisions et parsing HTTP, **sans aucune I/O**.
|
|
2
|
+
|
|
3
|
+
Ces fonctions reçoivent une `httpx.Response` déjà obtenue, elles n'en émettent jamais.
|
|
4
|
+
Conséquence : elles sont testées une seule fois et servent à l'identique aux deux clients
|
|
5
|
+
(sync et async), ce qui garantit un comportement strictement identique des deux côtés.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from brio_sdk._constants import (
|
|
21
|
+
HTTP_TOO_MANY_REQUESTS,
|
|
22
|
+
RETRY_AFTER_HEADER,
|
|
23
|
+
RETRYABLE_SERVER_STATUS,
|
|
24
|
+
SDK_VERSION,
|
|
25
|
+
SDK_VERSION_HEADER,
|
|
26
|
+
USER_AGENT,
|
|
27
|
+
)
|
|
28
|
+
from brio_sdk.exceptions import BrioServerError, map_error
|
|
29
|
+
|
|
30
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
31
|
+
|
|
32
|
+
_HTTP_ERROR_THRESHOLD = 400
|
|
33
|
+
_INVALID_RESPONSE_MESSAGE = "Réponse serveur non conforme au contrat attendu."
|
|
34
|
+
# En-tête que httpx pose par défaut : le remplacer ne piétine aucun choix de l'appelant.
|
|
35
|
+
_DEFAULT_HTTPX_USER_AGENT_PREFIX = "python-httpx/"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configure_client_identity(
|
|
39
|
+
client: httpx.Client | httpx.AsyncClient,
|
|
40
|
+
*,
|
|
41
|
+
base_url: str,
|
|
42
|
+
auth: httpx.Auth,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Applique l'identité du SDK à un client httpx, y compris injecté.
|
|
45
|
+
|
|
46
|
+
Un client fourni par l'appelant (proxy, mTLS, timeouts maison) ne doit pas perdre
|
|
47
|
+
l'authentification ni la `base_url` : sans ça, l'injection produit silencieusement des
|
|
48
|
+
appels anonymes vers une URL relative. On ne remplace en revanche ni une `base_url` ni
|
|
49
|
+
une auth déjà choisies explicitement, ni un `User-Agent` personnalisé.
|
|
50
|
+
"""
|
|
51
|
+
client.headers[SDK_VERSION_HEADER] = SDK_VERSION
|
|
52
|
+
current_user_agent = client.headers.get("User-Agent", "")
|
|
53
|
+
if current_user_agent.startswith(_DEFAULT_HTTPX_USER_AGENT_PREFIX):
|
|
54
|
+
client.headers["User-Agent"] = USER_AGENT
|
|
55
|
+
if not str(client.base_url):
|
|
56
|
+
client.base_url = base_url
|
|
57
|
+
if client.auth is None:
|
|
58
|
+
client.auth = auth
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def should_retry_status(status_code: int) -> bool:
|
|
62
|
+
"""Vrai si le statut mérite un réessai.
|
|
63
|
+
|
|
64
|
+
429 (quota, transitoire par nature) et 502/503/504 (indisponibilité amont). Les
|
|
65
|
+
autres 4xx sont des erreurs du client : les rejouer ne change rien.
|
|
66
|
+
"""
|
|
67
|
+
return status_code == HTTP_TOO_MANY_REQUESTS or status_code in RETRYABLE_SERVER_STATUS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compute_backoff(
|
|
71
|
+
attempt: int,
|
|
72
|
+
*,
|
|
73
|
+
base_seconds: float,
|
|
74
|
+
cap_seconds: float,
|
|
75
|
+
jitter_ratio: float,
|
|
76
|
+
) -> float:
|
|
77
|
+
"""Backoff exponentiel plafonné, avec jitter.
|
|
78
|
+
|
|
79
|
+
`attempt` est 0-indexé. Le jitter évite que N clients réessayent en même temps
|
|
80
|
+
(thundering herd) après une même 429.
|
|
81
|
+
"""
|
|
82
|
+
exponential = base_seconds * (2**attempt)
|
|
83
|
+
jitter = exponential * jitter_ratio * random.random() # noqa: S311 — pas d'usage cryptographique
|
|
84
|
+
return min(exponential + jitter, cap_seconds)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_retry_after(response: httpx.Response) -> float | None:
|
|
88
|
+
"""Lit l'en-tête `Retry-After` (secondes ou date HTTP). `None` s'il est absent/illisible.
|
|
89
|
+
|
|
90
|
+
Le serveur ne l'émet pas aujourd'hui ; dès qu'il le fera, il primera sur le backoff.
|
|
91
|
+
"""
|
|
92
|
+
raw = response.headers.get(RETRY_AFTER_HEADER)
|
|
93
|
+
if not raw:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return float(raw)
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
target = parsedate_to_datetime(raw)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
reference = datetime.now(target.tzinfo) if target.tzinfo is not None else datetime.now(timezone.utc) # noqa: UP017
|
|
107
|
+
return max((target - reference).total_seconds(), 0.0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decode_json(response: httpx.Response) -> Any | None:
|
|
111
|
+
"""Corps JSON décodé, ou `None` si le corps n'est pas du JSON exploitable."""
|
|
112
|
+
try:
|
|
113
|
+
return response.json()
|
|
114
|
+
except ValueError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def raise_for_error(response: httpx.Response) -> None:
|
|
119
|
+
"""Lève l'exception SDK correspondante si la réponse est une erreur."""
|
|
120
|
+
if response.status_code < _HTTP_ERROR_THRESHOLD:
|
|
121
|
+
return
|
|
122
|
+
raise map_error(
|
|
123
|
+
response.status_code,
|
|
124
|
+
decode_json(response),
|
|
125
|
+
retry_after=parse_retry_after(response),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_response(response: httpx.Response, model: type[ModelT]) -> ModelT:
|
|
130
|
+
"""Valide une réponse de succès dans `model`, ou lève l'exception SDK adéquate.
|
|
131
|
+
|
|
132
|
+
Un corps de succès non conforme est une anomalie serveur, pas une erreur client :
|
|
133
|
+
on lève `BrioServerError` en gardant le payload brut pour le diagnostic.
|
|
134
|
+
"""
|
|
135
|
+
raise_for_error(response)
|
|
136
|
+
|
|
137
|
+
payload = decode_json(response)
|
|
138
|
+
try:
|
|
139
|
+
return model.model_validate(payload)
|
|
140
|
+
except ValidationError as error:
|
|
141
|
+
raise BrioServerError(
|
|
142
|
+
_INVALID_RESPONSE_MESSAGE,
|
|
143
|
+
status_code=response.status_code,
|
|
144
|
+
response_payload=payload,
|
|
145
|
+
) from error
|
brio_sdk/_payloads.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Construction des corps de requête et petites règles de polling — cœur pur, sans I/O.
|
|
2
|
+
|
|
3
|
+
Factorisé ici pour que les deux clients (sync et async) partagent exactement les mêmes
|
|
4
|
+
règles : un seul endroit à tester, aucune dérive possible entre les deux surfaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from brio_sdk.models import PresignedUploadResponse
|
|
17
|
+
|
|
18
|
+
_TIMEOUT_MESSAGE_TEMPLATE = (
|
|
19
|
+
"Délai d'attente de {timeout:g}s dépassé : la tâche est toujours en cours côté serveur. "
|
|
20
|
+
"Relancez wait_for_result avec le même task_id pour continuer à attendre."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def dump_body(body: Mapping[str, Any] | BaseModel) -> dict[str, Any]:
|
|
25
|
+
"""Normalise un body en dict JSON-sérialisable.
|
|
26
|
+
|
|
27
|
+
Un modèle typé est dumpé avec `exclude_none` : les champs optionnels non renseignés
|
|
28
|
+
ne sont pas envoyés, ce qui laisse le serveur appliquer ses propres défauts.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(body, BaseModel):
|
|
31
|
+
return body.model_dump(mode="json", exclude_none=True)
|
|
32
|
+
return dict(body)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_task_payload(
|
|
36
|
+
body: Mapping[str, Any] | BaseModel,
|
|
37
|
+
callback: Mapping[str, Any] | BaseModel | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Corps de `POST /v1/services/{service}/tasks`."""
|
|
40
|
+
payload: dict[str, Any] = {"body": dump_body(body)}
|
|
41
|
+
if callback is not None:
|
|
42
|
+
payload["callback"] = dump_body(callback)
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_presigned_upload_fields(presigned: PresignedUploadResponse) -> dict[str, str]:
|
|
47
|
+
"""Champs de formulaire à renvoyer tels quels dans le POST S3, avant le champ `file`."""
|
|
48
|
+
return dict(presigned.fields)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def poll_interval_after(interval_seconds: float, max_interval_seconds: float) -> float:
|
|
52
|
+
"""Intervalle du prochain poll : doublement plafonné."""
|
|
53
|
+
return min(interval_seconds * 2, max_interval_seconds)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def timeout_message(timeout_seconds: float) -> str:
|
|
57
|
+
"""Message d'un dépassement de délai — dit explicitement que la tâche continue."""
|
|
58
|
+
return _TIMEOUT_MESSAGE_TEMPLATE.format(timeout=timeout_seconds)
|
brio_sdk/_upload.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Préparation d'un upload — mesure de taille et type MIME, sans charger le fichier en RAM.
|
|
2
|
+
|
|
3
|
+
Le consommateur ne doit pas connaître le seuil de bascule API/S3 : c'est `resolve_source`
|
|
4
|
+
qui donne la taille, et le client qui choisit la voie. La taille est obtenue par `stat()`
|
|
5
|
+
pour un chemin et par `seek/tell` pour un flux — jamais en lisant le contenu, sinon un
|
|
6
|
+
scan de 200 Mo tiendrait en mémoire.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import mimetypes
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
from brio_sdk._constants import DEFAULT_MIME_TYPE, DIRECT_UPLOAD_MAX_SIZE_BYTES
|
|
17
|
+
from brio_sdk.exceptions import BrioConfigError
|
|
18
|
+
|
|
19
|
+
_UNKNOWN_SIZE_MESSAGE = (
|
|
20
|
+
"Impossible de mesurer la taille du flux fourni (non seekable). "
|
|
21
|
+
"Passez un chemin de fichier, ou un objet binaire seekable."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class UploadSource:
|
|
27
|
+
"""Source d'upload prête à être envoyée : nom, type MIME, taille et flux."""
|
|
28
|
+
|
|
29
|
+
filename: str
|
|
30
|
+
mime_type: str
|
|
31
|
+
size_bytes: int
|
|
32
|
+
stream: BinaryIO
|
|
33
|
+
# Vrai si le flux a été ouvert par le SDK : c'est alors à lui de le refermer.
|
|
34
|
+
owns_stream: bool
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
if self.owns_stream:
|
|
38
|
+
self.stream.close()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def needs_presigned_upload(self) -> bool:
|
|
42
|
+
"""Vrai si le fichier dépasse ce que la voie directe (transit API) accepte."""
|
|
43
|
+
return self.size_bytes >= DIRECT_UPLOAD_MAX_SIZE_BYTES
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def guess_mime_type(filename: str) -> str:
|
|
47
|
+
"""Type MIME déduit de l'extension, avec repli sur `application/octet-stream`."""
|
|
48
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
49
|
+
return guessed or DEFAULT_MIME_TYPE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stream_size(stream: BinaryIO) -> int:
|
|
53
|
+
"""Taille restante d'un flux seekable, sans en lire le contenu."""
|
|
54
|
+
if not stream.seekable():
|
|
55
|
+
raise BrioConfigError(_UNKNOWN_SIZE_MESSAGE)
|
|
56
|
+
current = stream.tell()
|
|
57
|
+
size = stream.seek(0, 2) - current
|
|
58
|
+
stream.seek(current)
|
|
59
|
+
return size
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_source(
|
|
63
|
+
file: Path | str | BinaryIO,
|
|
64
|
+
filename: str | None = None,
|
|
65
|
+
mime_type: str | None = None,
|
|
66
|
+
) -> UploadSource:
|
|
67
|
+
"""Normalise un chemin ou un flux binaire en `UploadSource`.
|
|
68
|
+
|
|
69
|
+
Le flux d'un chemin est ouvert ici (`owns_stream=True`) : le client le refermera.
|
|
70
|
+
Un flux fourni par l'appelant lui reste, on n'y touche pas.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(file, (str, Path)):
|
|
73
|
+
path = Path(file)
|
|
74
|
+
resolved_name = filename or path.name
|
|
75
|
+
return UploadSource(
|
|
76
|
+
filename=resolved_name,
|
|
77
|
+
mime_type=mime_type or guess_mime_type(resolved_name),
|
|
78
|
+
size_bytes=path.stat().st_size,
|
|
79
|
+
stream=path.open("rb"),
|
|
80
|
+
owns_stream=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
resolved_name = filename or getattr(file, "name", None) or "upload.bin"
|
|
84
|
+
resolved_name = Path(str(resolved_name)).name
|
|
85
|
+
return UploadSource(
|
|
86
|
+
filename=resolved_name,
|
|
87
|
+
mime_type=mime_type or guess_mime_type(resolved_name),
|
|
88
|
+
size_bytes=_stream_size(file),
|
|
89
|
+
stream=file,
|
|
90
|
+
owns_stream=False,
|
|
91
|
+
)
|