alea-q 0.2.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.

Potentially problematic release.


This version of alea-q might be problematic. Click here for more details.

alea_q/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ # alea_q/__init__.py
2
+ """
3
+ alea-q — SDK Python pour l'API ALEA-Q / CERTROPI.
4
+
5
+ Génération d'entiers aléatoires certifiés Device-Independent
6
+ depuis des QPU réels (IBM, Quandela, Pasqal).
7
+
8
+ Usage rapide :
9
+ from alea_q import AleaQClient
10
+
11
+ client = AleaQClient(api_key="sk_plat_xxx")
12
+ result = client.platinum.generate(n_bits=256)
13
+ print(result.as_hex())
14
+
15
+ Documentation : https://docs.alea-q.com
16
+ """
17
+ from alea_q.client import AleaQClient, AsyncAleaQClient
18
+ from alea_q.exceptions import (
19
+ AleaQError,
20
+ AuthenticationError,
21
+ BackendUnavailable,
22
+ CertificateError,
23
+ InsufficientBalance,
24
+ PermissionError,
25
+ QuotaExceeded,
26
+ RateLimitError,
27
+ ServerError,
28
+ ValidationError,
29
+ )
30
+ from alea_q.models import (
31
+ AccountInfo,
32
+ BatchResult,
33
+ GenerateResult,
34
+ HealthStatus,
35
+ MLKemEncapsResult,
36
+ MLKemKeyPair,
37
+ PoolStatus,
38
+ QuotaInfo,
39
+ SLHDSAKeyPair,
40
+ SLHDSASignResult,
41
+ SLHDSAVerifyResult,
42
+ UsageStats,
43
+ )
44
+ from alea_q.pqc_crypto import DecapsulationError, mlkem_decaps
45
+ from alea_q.verify import verify_certificate, verify_certificate_dict
46
+
47
+ __version__ = "0.2.0"
48
+ __all__ = [
49
+ # Clients
50
+ "AleaQClient",
51
+ "AsyncAleaQClient",
52
+ # Modèles
53
+ "GenerateResult",
54
+ "BatchResult",
55
+ "PoolStatus",
56
+ "HealthStatus",
57
+ "AccountInfo",
58
+ "QuotaInfo",
59
+ "UsageStats",
60
+ "MLKemKeyPair",
61
+ "MLKemEncapsResult",
62
+ "SLHDSAKeyPair",
63
+ "SLHDSASignResult",
64
+ "SLHDSAVerifyResult",
65
+ # Exceptions
66
+ "AleaQError",
67
+ "AuthenticationError",
68
+ "PermissionError",
69
+ "QuotaExceeded",
70
+ "BackendUnavailable",
71
+ "RateLimitError",
72
+ "ServerError",
73
+ "CertificateError",
74
+ "ValidationError",
75
+ "InsufficientBalance",
76
+ # Vérification certificat
77
+ "verify_certificate",
78
+ "verify_certificate_dict",
79
+ # Décapsulation ML-KEM locale (#438)
80
+ "mlkem_decaps",
81
+ "DecapsulationError",
82
+ ]
83
+
alea_q/_http.py ADDED
@@ -0,0 +1,220 @@
1
+ # alea_q/_http.py
2
+ """
3
+ Couche HTTP du SDK alea-q — gestion centralisée des requêtes et des erreurs.
4
+
5
+ Utilisé par AleaQClient (sync) et AsyncAleaQClient (async).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, Dict, Optional
11
+
12
+ import httpx
13
+
14
+ from alea_q.exceptions import (
15
+ AleaQError,
16
+ AuthenticationError,
17
+ BackendUnavailable,
18
+ InsufficientBalance,
19
+ PermissionError,
20
+ QuotaExceeded,
21
+ RateLimitError,
22
+ ServerError,
23
+ ValidationError,
24
+ )
25
+
26
+ # Version du SDK — mise à jour à chaque release
27
+ SDK_VERSION = "0.2.0"
28
+
29
+ DEFAULT_BASE_URL = os.getenv("ALEA_Q_BASE_URL", "https://api.alea-q.com")
30
+ DEFAULT_TIMEOUT = float(os.getenv("ALEA_Q_TIMEOUT", "60"))
31
+ DEFAULT_RETRIES = int(os.getenv("ALEA_Q_MAX_RETRIES", "2"))
32
+
33
+
34
+ def _resolve_api_key(api_key: Optional[str]) -> str:
35
+ """
36
+ Résout la clé API depuis le paramètre ou la variable d'environnement.
37
+ Lève ValidationError si aucune clé n'est disponible.
38
+ """
39
+ key = api_key or os.getenv("ALEA_Q_API_KEY", "")
40
+ if not key:
41
+ raise ValidationError(
42
+ "Clé API manquante. Passez api_key= au constructeur ou "
43
+ "définissez la variable d'environnement ALEA_Q_API_KEY."
44
+ )
45
+ return key
46
+
47
+
48
+ # Préfixes réels des clés API (api/auth.py côté serveur) — sk_ivor_, pas
49
+ # sk_ivory_ (#116, faute de frappe corrigée dans la doc du SDK au passage).
50
+ _TIER_PREFIXES = {
51
+ "sk_ivor_": "ivory",
52
+ "sk_silv_": "silver",
53
+ "sk_plat_": "platinum",
54
+ }
55
+
56
+
57
+ def _detect_tier(api_key: str) -> Optional[str]:
58
+ """
59
+ Détecte le tier ('ivory' | 'silver' | 'platinum') depuis le préfixe
60
+ de la clé API (#116) — best-effort, informationnel.
61
+
62
+ Retourne None si le préfixe n'est pas reconnu, plutôt que de lever :
63
+ la validation qui compte réellement se fait côté serveur (401 sur
64
+ une clé invalide, via _raise_for_status ci-dessous) — bloquer la
65
+ construction du client sur un préfixe inconnu casserait des clés
66
+ de test/staging légitimes qui ne suivent pas cette convention,
67
+ pour une garantie que le serveur apporte de toute façon.
68
+ """
69
+ for prefix, tier in _TIER_PREFIXES.items():
70
+ if api_key.startswith(prefix):
71
+ return tier
72
+ return None
73
+
74
+
75
+ def _build_headers(api_key: str) -> Dict[str, str]:
76
+ return {
77
+ "X-API-Key": api_key,
78
+ "User-Agent": f"alea-q-python/{SDK_VERSION}",
79
+ "Accept": "application/json",
80
+ "Content-Type": "application/json",
81
+ }
82
+
83
+
84
+ def _raise_for_status(response: httpx.Response) -> None:
85
+ """
86
+ Convertit les erreurs HTTP en exceptions typées alea-q.
87
+ Extrait request_id et retry_after si disponibles.
88
+ """
89
+ if response.status_code < 400:
90
+ return
91
+
92
+ request_id = response.headers.get("x-request-id")
93
+ retry_after_raw = response.headers.get("retry-after")
94
+ retry_after = int(retry_after_raw) if retry_after_raw and retry_after_raw.isdigit() else None
95
+
96
+ try:
97
+ body = response.json()
98
+ detail = body.get("detail", str(response.status_code))
99
+ reason = body.get("reason")
100
+ except Exception:
101
+ detail = response.text or str(response.status_code)
102
+ reason = None
103
+
104
+ sc = response.status_code
105
+
106
+ if sc == 401:
107
+ raise AuthenticationError(detail, status_code=sc, request_id=request_id)
108
+ if sc == 403:
109
+ raise PermissionError(detail, status_code=sc, request_id=request_id)
110
+ if sc == 422:
111
+ raise ValidationError(detail, status_code=sc, request_id=request_id)
112
+ if sc == 402:
113
+ raise InsufficientBalance(detail, status_code=sc, request_id=request_id)
114
+ if sc == 429:
115
+ # Distinguer rate-limit (Caddy) et quota (applicatif)
116
+ if reason or "quota" in detail.lower():
117
+ raise QuotaExceeded(
118
+ detail, retry_after=retry_after,
119
+ status_code=sc, request_id=request_id,
120
+ )
121
+ raise RateLimitError(
122
+ detail, retry_after=retry_after,
123
+ status_code=sc, request_id=request_id,
124
+ )
125
+ if sc == 503:
126
+ raise BackendUnavailable(
127
+ detail, retry_after=retry_after, reason=reason,
128
+ status_code=sc, request_id=request_id,
129
+ )
130
+ if sc >= 500:
131
+ raise ServerError(detail, status_code=sc, request_id=request_id)
132
+
133
+ raise AleaQError(detail, status_code=sc, request_id=request_id)
134
+
135
+
136
+ # ── Client synchrone ──────────────────────────────────────────
137
+
138
+ class HttpClient:
139
+ """Client HTTP synchrone (httpx)."""
140
+
141
+ def __init__(
142
+ self,
143
+ api_key: str,
144
+ base_url: str,
145
+ timeout: float,
146
+ ) -> None:
147
+ self._headers = _build_headers(api_key)
148
+ self._client = httpx.Client(
149
+ base_url = base_url,
150
+ headers = self._headers,
151
+ timeout = timeout,
152
+ )
153
+
154
+ def post(self, path: str, json: Dict[str, Any]) -> Dict[str, Any]:
155
+ response = self._client.post(path, json=json)
156
+ _raise_for_status(response)
157
+ return response.json()
158
+
159
+ def get(self, path: str, params: Optional[Dict] = None) -> Dict[str, Any]:
160
+ response = self._client.get(path, params=params)
161
+ _raise_for_status(response)
162
+ return response.json()
163
+
164
+ def get_bytes(self, path: str) -> bytes:
165
+ response = self._client.get(path)
166
+ _raise_for_status(response)
167
+ return response.content
168
+
169
+ def close(self) -> None:
170
+ self._client.close()
171
+
172
+ def __enter__(self) -> "HttpClient":
173
+ return self
174
+
175
+ def __exit__(self, *args) -> None:
176
+ self.close()
177
+
178
+
179
+ # ── Client asynchrone ─────────────────────────────────────────
180
+
181
+ class AsyncHttpClient:
182
+ """Client HTTP asynchrone (httpx.AsyncClient)."""
183
+
184
+ def __init__(
185
+ self,
186
+ api_key: str,
187
+ base_url: str,
188
+ timeout: float,
189
+ ) -> None:
190
+ self._headers = _build_headers(api_key)
191
+ self._client = httpx.AsyncClient(
192
+ base_url = base_url,
193
+ headers = self._headers,
194
+ timeout = timeout,
195
+ )
196
+
197
+ async def post(self, path: str, json: Dict[str, Any]) -> Dict[str, Any]:
198
+ response = await self._client.post(path, json=json)
199
+ _raise_for_status(response)
200
+ return response.json()
201
+
202
+ async def get(self, path: str, params: Optional[Dict] = None) -> Dict[str, Any]:
203
+ response = await self._client.get(path, params=params)
204
+ _raise_for_status(response)
205
+ return response.json()
206
+
207
+ async def get_bytes(self, path: str) -> bytes:
208
+ response = await self._client.get(path)
209
+ _raise_for_status(response)
210
+ return response.content
211
+
212
+ async def close(self) -> None:
213
+ await self._client.aclose()
214
+
215
+ async def __aenter__(self) -> "AsyncHttpClient":
216
+ return self
217
+
218
+ async def __aexit__(self, *args) -> None:
219
+ await self.close()
220
+
alea_q/client.py ADDED
@@ -0,0 +1,181 @@
1
+ # alea_q/client.py
2
+ """
3
+ Clients principaux du SDK alea-q.
4
+
5
+ Usage sync :
6
+ from alea_q import AleaQClient
7
+
8
+ client = AleaQClient(api_key="sk_plat_xxx")
9
+
10
+ # Platinum
11
+ result = client.platinum.generate(n_bits=256)
12
+ print(result.as_hex())
13
+
14
+ # Silver
15
+ result = client.silver.generate(n_bits=128)
16
+
17
+ # Ivory batch
18
+ batch = client.ivory.batch(size=1000, n_bits=32)
19
+
20
+ # Vérification certificat
21
+ client.platinum.generate(n_bits=256, verify=True, pubkey_path="certropi_public.pem")
22
+
23
+ # Context manager
24
+ with AleaQClient() as client:
25
+ result = client.platinum.generate()
26
+
27
+ Usage async :
28
+ from alea_q import AsyncAleaQClient
29
+
30
+ async with AsyncAleaQClient(api_key="sk_plat_xxx") as client:
31
+ result = await client.platinum.generate(n_bits=256)
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from typing import Optional
36
+
37
+ from alea_q._http import (
38
+ DEFAULT_BASE_URL,
39
+ DEFAULT_TIMEOUT,
40
+ AsyncHttpClient,
41
+ HttpClient,
42
+ _detect_tier,
43
+ _resolve_api_key,
44
+ )
45
+ from alea_q.models import AccountInfo, HealthStatus
46
+ from alea_q.tiers.ivory import AsyncIvoryTier, IvoryTier
47
+ from alea_q.tiers.platinum import AsyncPlatinumTier, PlatinumTier
48
+ from alea_q.tiers.silver import AsyncSilverTier, SilverTier
49
+
50
+
51
+ class AleaQClient:
52
+ """
53
+ Client synchrone ALEA-Q / CERTROPI.
54
+
55
+ Paramètres
56
+ ----------
57
+ api_key : clé API (sk_plat_xxx, sk_silv_xxx, sk_ivor_xxx).
58
+ Si absent, lit ALEA_Q_API_KEY depuis l'environnement.
59
+ base_url : URL de base de l'API (défaut : https://api.alea-q.com).
60
+ Surcharger avec ALEA_Q_BASE_URL pour staging/dev.
61
+ timeout : timeout HTTP en secondes (défaut : 60).
62
+
63
+ Attributs
64
+ ---------
65
+ tier : tier détecté depuis le préfixe de la clé (#116) —
66
+ 'ivory' | 'silver' | 'platinum', ou None si le préfixe
67
+ n'est pas reconnu (informationnel ; la validation
68
+ réelle de la clé se fait côté serveur).
69
+ platinum : sous-client Platinum (génération DI-certifiée)
70
+ silver : sous-client Silver (DRBG seedé QPU)
71
+ ivory : sous-client Ivory (simulateur Toeplitz)
72
+
73
+ Exemple
74
+ -------
75
+ >>> client = AleaQClient(api_key="sk_plat_xxx")
76
+ >>> result = client.platinum.generate(n_bits=256)
77
+ >>> print(result.as_hex())
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ api_key: Optional[str] = None,
83
+ base_url: str = DEFAULT_BASE_URL,
84
+ timeout: float = DEFAULT_TIMEOUT,
85
+ ) -> None:
86
+ resolved_key = _resolve_api_key(api_key)
87
+ self.tier = _detect_tier(resolved_key)
88
+ self._http = HttpClient(
89
+ api_key = resolved_key,
90
+ base_url = base_url,
91
+ timeout = timeout,
92
+ )
93
+ self.platinum = PlatinumTier(self._http)
94
+ self.silver = SilverTier(self._http)
95
+ self.ivory = IvoryTier(self._http)
96
+
97
+ def health(self) -> HealthStatus:
98
+ """
99
+ Retourne l'état du service ALEA-Q.
100
+ Requiert une clé admin pour la réponse complète.
101
+ """
102
+ data = self._http.get("/health")
103
+ return HealthStatus.from_dict(data)
104
+
105
+ def me(self) -> AccountInfo:
106
+ """
107
+ Informations du compte authentifié (#116) — tier, quota
108
+ journalier restant et statistiques d'usage cumulées.
109
+ """
110
+ data = self._http.get("/v1/me")
111
+ return AccountInfo.from_dict(data)
112
+
113
+ def close(self) -> None:
114
+ """Ferme le client HTTP et libère les ressources."""
115
+ self._http.close()
116
+
117
+ def __enter__(self) -> "AleaQClient":
118
+ return self
119
+
120
+ def __exit__(self, *args) -> None:
121
+ self.close()
122
+
123
+ def __repr__(self) -> str:
124
+ return f"AleaQClient(base_url={self._http._client.base_url!r})"
125
+
126
+
127
+ class AsyncAleaQClient:
128
+ """
129
+ Client asynchrone ALEA-Q / CERTROPI.
130
+
131
+ Paramètres
132
+ ----------
133
+ api_key : clé API. Si absent, lit ALEA_Q_API_KEY depuis l'environnement.
134
+ base_url : URL de base de l'API.
135
+ timeout : timeout HTTP en secondes.
136
+
137
+ Exemple
138
+ -------
139
+ >>> async with AsyncAleaQClient(api_key="sk_plat_xxx") as client:
140
+ ... result = await client.platinum.generate(n_bits=256)
141
+ ... print(result.as_hex())
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ api_key: Optional[str] = None,
147
+ base_url: str = DEFAULT_BASE_URL,
148
+ timeout: float = DEFAULT_TIMEOUT,
149
+ ) -> None:
150
+ resolved_key = _resolve_api_key(api_key)
151
+ self.tier = _detect_tier(resolved_key)
152
+ self._http = AsyncHttpClient(
153
+ api_key = resolved_key,
154
+ base_url = base_url,
155
+ timeout = timeout,
156
+ )
157
+ self.platinum = AsyncPlatinumTier(self._http)
158
+ self.silver = AsyncSilverTier(self._http)
159
+ self.ivory = AsyncIvoryTier(self._http)
160
+
161
+ async def health(self) -> HealthStatus:
162
+ data = await self._http.get("/health")
163
+ return HealthStatus.from_dict(data)
164
+
165
+ async def me(self) -> AccountInfo:
166
+ """Informations du compte authentifié (#116) — voir AleaQClient.me()."""
167
+ data = await self._http.get("/v1/me")
168
+ return AccountInfo.from_dict(data)
169
+
170
+ async def close(self) -> None:
171
+ await self._http.close()
172
+
173
+ async def __aenter__(self) -> "AsyncAleaQClient":
174
+ return self
175
+
176
+ async def __aexit__(self, *args) -> None:
177
+ await self.close()
178
+
179
+ def __repr__(self) -> str:
180
+ return f"AsyncAleaQClient(base_url={self._http._client.base_url!r})"
181
+
alea_q/exceptions.py ADDED
@@ -0,0 +1,120 @@
1
+ # alea_q/exceptions.py
2
+ """
3
+ Exceptions typées du SDK alea-q.
4
+
5
+ Hiérarchie :
6
+ AleaQError — base commune
7
+ AuthenticationError — clé API invalide ou absente (401)
8
+ PermissionError — tier insuffisant (403)
9
+ QuotaExceeded — quota de complexité dépassé (429)
10
+ BackendUnavailable — backend QPU indisponible (503)
11
+ RateLimitError — trop de requêtes (429 rate-limit)
12
+ ServerError — erreur interne serveur (500)
13
+ CertificateError — erreur de vérification d'attestation
14
+ ValidationError — paramètres invalides côté client
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+
21
+ class AleaQError(Exception):
22
+ """Exception de base du SDK alea-q."""
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ status_code: Optional[int] = None,
28
+ request_id: Optional[str] = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.request_id = request_id
33
+
34
+ def __repr__(self) -> str:
35
+ return (
36
+ f"{self.__class__.__name__}("
37
+ f"message={str(self)!r}, "
38
+ f"status_code={self.status_code}, "
39
+ f"request_id={self.request_id!r})"
40
+ )
41
+
42
+
43
+ class AuthenticationError(AleaQError):
44
+ """Clé API invalide, expirée ou absente. (HTTP 401)"""
45
+
46
+
47
+ class PermissionError(AleaQError):
48
+ """Tier de la clé insuffisant pour cette opération. (HTTP 403)"""
49
+
50
+
51
+ class QuotaExceeded(AleaQError):
52
+ """Quota de complexité ou de requêtes dépassé. (HTTP 429)"""
53
+
54
+ def __init__(
55
+ self,
56
+ message: str,
57
+ retry_after: Optional[int] = None,
58
+ **kwargs,
59
+ ) -> None:
60
+ super().__init__(message, **kwargs)
61
+ self.retry_after = retry_after
62
+
63
+
64
+ class BackendUnavailable(AleaQError):
65
+ """
66
+ Backend QPU temporairement indisponible — pool vide ou en maintenance.
67
+ (HTTP 503)
68
+
69
+ Attributs
70
+ ---------
71
+ retry_after : secondes avant de réessayer (depuis l'en-tête Retry-After)
72
+ reason : cause technique ('pool_empty_no_backend', 'backend_unavailable', etc.)
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ retry_after: Optional[int] = None,
79
+ reason: Optional[str] = None,
80
+ **kwargs,
81
+ ) -> None:
82
+ super().__init__(message, **kwargs)
83
+ self.retry_after = retry_after
84
+ self.reason = reason
85
+
86
+
87
+ class RateLimitError(AleaQError):
88
+ """Trop de requêtes — rate-limit atteint. (HTTP 429)"""
89
+
90
+ def __init__(
91
+ self,
92
+ message: str,
93
+ retry_after: Optional[int] = None,
94
+ **kwargs,
95
+ ) -> None:
96
+ super().__init__(message, **kwargs)
97
+ self.retry_after = retry_after
98
+
99
+
100
+ class ServerError(AleaQError):
101
+ """Erreur interne serveur ALEA-Q. (HTTP 500)"""
102
+
103
+
104
+ class InsufficientBalance(AleaQError):
105
+ """
106
+ Solde prépayé insuffisant pour honorer la requête. (HTTP 402)
107
+
108
+ #178/#179 — jamais câblé dans le SDK avant cette découverte en
109
+ travaillant sur le streaming Silver : le mode pay-as-you-go existe
110
+ côté API depuis #178 mais ne levait qu'une AleaQError générique.
111
+ """
112
+
113
+
114
+ class CertificateError(AleaQError):
115
+ """Erreur lors de la vérification d'un certificat d'attestation Platinum."""
116
+
117
+
118
+ class ValidationError(AleaQError):
119
+ """Paramètres de requête invalides (vérification côté client avant envoi)."""
120
+