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 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
+ )