senndo 0.1.0__tar.gz
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.
- senndo-0.1.0/.gitignore +44 -0
- senndo-0.1.0/CHANGELOG.md +26 -0
- senndo-0.1.0/LICENSE +21 -0
- senndo-0.1.0/PKG-INFO +287 -0
- senndo-0.1.0/README.md +244 -0
- senndo-0.1.0/pyproject.toml +53 -0
- senndo-0.1.0/src/senndo/__init__.py +88 -0
- senndo-0.1.0/src/senndo/_generated/__init__.py +1 -0
- senndo-0.1.0/src/senndo/_generated/contract.py +1235 -0
- senndo-0.1.0/src/senndo/_http.py +264 -0
- senndo-0.1.0/src/senndo/client.py +371 -0
- senndo-0.1.0/src/senndo/errors.py +182 -0
- senndo-0.1.0/src/senndo/py.typed +0 -0
- senndo-0.1.0/src/senndo/types.py +87 -0
senndo-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Builds
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.svelte-kit/
|
|
9
|
+
*.tsbuildinfo
|
|
10
|
+
|
|
11
|
+
# Environnement — les secrets ne rentrent JAMAIS dans ce repo
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
# Docs privés (credentials, drafts hérités) — jamais committés
|
|
17
|
+
private_docs/
|
|
18
|
+
|
|
19
|
+
# Screenshots de vérification (générés par beat, lus localement par le verifier)
|
|
20
|
+
artifacts/
|
|
21
|
+
|
|
22
|
+
# PID des serveurs e2e (make app-up / app-down)
|
|
23
|
+
.pids/
|
|
24
|
+
|
|
25
|
+
# Divers
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.log
|
|
28
|
+
coverage/
|
|
29
|
+
playwright-report/
|
|
30
|
+
test-results/
|
|
31
|
+
|
|
32
|
+
# SDK Python — environnement virtuel local et artefacts d'empaquetage
|
|
33
|
+
packages/sdk-python/.venv/
|
|
34
|
+
packages/sdk-python/dist/
|
|
35
|
+
packages/sdk-python/**/__pycache__/
|
|
36
|
+
packages/sdk-python/**/*.egg-info/
|
|
37
|
+
packages/sdk-python/.mypy_cache/
|
|
38
|
+
packages/sdk-python/.pytest_cache/
|
|
39
|
+
|
|
40
|
+
# SDK PHP — dépendances Composer (require-dev seulement : le paquet publié n'en a aucune)
|
|
41
|
+
packages/sdk-php/vendor/
|
|
42
|
+
packages/sdk-php/composer.lock
|
|
43
|
+
packages/sdk-php/.phpunit.cache/
|
|
44
|
+
packages/sdk-php/.phpunit.result.cache
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Journal des versions — `senndo` (Python)
|
|
2
|
+
|
|
3
|
+
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage
|
|
4
|
+
sémantique.
|
|
5
|
+
|
|
6
|
+
## 0.1.0 — 2026-08-02
|
|
7
|
+
|
|
8
|
+
Première publication. La version reste `0.x` tant que la surface n'a pas été exercée par des
|
|
9
|
+
intégrations réelles : un `1.0.0` promet une stabilité que rien n'a encore éprouvée.
|
|
10
|
+
|
|
11
|
+
### Ajouté
|
|
12
|
+
|
|
13
|
+
- `SenndoClient` — les 20 opérations de l'API publique, une méthode par opération, nommées en
|
|
14
|
+
snake_case depuis les identifiants du contrat.
|
|
15
|
+
- Types générés depuis le contrat OpenAPI de senndo : corps, paramètres de requête et réponses en
|
|
16
|
+
`TypedDict`, énumérations en `Literal`.
|
|
17
|
+
- Erreurs typées par famille (`SenndoInsufficientFundsError`, `SenndoRateLimitError`,
|
|
18
|
+
`SenndoValidationError`…) — on branche sur une classe ou sur `error.code`, jamais sur un message.
|
|
19
|
+
- Idempotence de première classe : `idempotencyKey` obligatoire sur l'envoi, préfixes réservés
|
|
20
|
+
refusés localement, `new_idempotency_key()` pour les cas sans clé métier.
|
|
21
|
+
- Retentatives limitées à ce qui est rejouable : `GET`/`DELETE` et les `POST` porteurs d'une clé
|
|
22
|
+
d'idempotence, sur échec de transport, 429 et 5xx uniquement.
|
|
23
|
+
- Délais explicites (30 s par défaut), surchargeables par appel via `RequestOptions`.
|
|
24
|
+
- Transport injectable (`types.Transport`) — `urllib.request` par défaut, aucune dépendance.
|
|
25
|
+
- Décodage `parse_float=Decimal` : aucun nombre à virgule ne traverse un flottant.
|
|
26
|
+
- Clé API masquée dans `repr()` et `str()`.
|
senndo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 senndo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
senndo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: senndo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the senndo messaging and verification API.
|
|
5
|
+
Project-URL: Homepage, https://senndo.com
|
|
6
|
+
Project-URL: Documentation, https://senndo.com/developpeurs
|
|
7
|
+
Project-URL: Changelog, https://github.com/senndo/senndo-python/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Source, https://github.com/senndo/senndo-python
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 senndo
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: cpaas,email,messaging,otp,senndo,sms,voice,whatsapp
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: Communications
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.11
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# senndo — SDK Python officiel
|
|
45
|
+
|
|
46
|
+
Messagerie multicanale et vérification : SMS, WhatsApp, e-mail, voix, OTP. Une seule API, un seul
|
|
47
|
+
solde, un verdict de livraison par message.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv add senndo
|
|
51
|
+
pip install senndo
|
|
52
|
+
poetry add senndo
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Python ≥ 3.11. **Aucune dépendance d'exécution.**
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Premier envoi
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
senndo = SenndoClient(api_key=cle_api)
|
|
63
|
+
|
|
64
|
+
envoi = senndo.send_message(
|
|
65
|
+
{
|
|
66
|
+
"channel": "sms",
|
|
67
|
+
"to": "+33612345678",
|
|
68
|
+
"text": "Votre code de connexion est 4821.",
|
|
69
|
+
"idempotencyKey": f"connexion-{utilisateur_id}",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
print(envoi["id"], envoi["status"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`send_message` rend l'état **au moment de l'acceptation**, pas le verdict final. Un `sent` dit que
|
|
77
|
+
l'opérateur a pris le message ; il ne dit pas qu'il est arrivé. Le verdict se lit sur un webhook, ou
|
|
78
|
+
en relisant le message.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
message = senndo.get_message(identifiant_du_message)
|
|
82
|
+
|
|
83
|
+
if message["status"] == "failed":
|
|
84
|
+
journaliser("échec", message.get("failureCode"))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## La clé d'idempotence : le SDK n'en fabrique pas à votre place
|
|
90
|
+
|
|
91
|
+
`idempotencyKey` est **obligatoire** sur tout envoi, et c'est délibéré. Rejouer la même clé renvoie
|
|
92
|
+
le message déjà créé — sans jamais redébiter le compte.
|
|
93
|
+
|
|
94
|
+
Le SDK **n'en pose jamais une pour vous**. Une clé inventée au moment de l'appel serait perdue si le
|
|
95
|
+
processus meurt entre l'envoi et la réponse : exactement le cas où l'idempotence sert. La bonne clé
|
|
96
|
+
vient de votre domaine.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# CORRECT : la clé survit à un redémarrage, parce qu'elle vient de votre base.
|
|
100
|
+
senndo.send_message(
|
|
101
|
+
{
|
|
102
|
+
"channel": "whatsapp_cloud",
|
|
103
|
+
"to": commande["telephone"],
|
|
104
|
+
"text": "Votre commande est prête.",
|
|
105
|
+
"idempotencyKey": f"commande-{commande['reference']}-prete",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`new_idempotency_key()` existe pour les cas où il n'y a **vraiment** rien à dériver — un envoi
|
|
111
|
+
manuel depuis un script, un test :
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
senndo.send_message(
|
|
115
|
+
{
|
|
116
|
+
"channel": "sms",
|
|
117
|
+
"to": "+15551234567",
|
|
118
|
+
"text": "Essai.",
|
|
119
|
+
"idempotencyKey": new_idempotency_key("essai-"),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Les erreurs se branchent sur une classe, jamais sur un message
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
try:
|
|
130
|
+
senndo.send_message(
|
|
131
|
+
{
|
|
132
|
+
"channel": "sms",
|
|
133
|
+
"to": "+22507000000",
|
|
134
|
+
"text": "Bonjour.",
|
|
135
|
+
"idempotencyKey": f"bienvenue-{utilisateur_id}",
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
except SenndoInsufficientFundsError:
|
|
139
|
+
recharger_le_compte()
|
|
140
|
+
except SenndoValidationError as erreur:
|
|
141
|
+
journaliser("appel à corriger", erreur.code, erreur.api_message)
|
|
142
|
+
except SenndoRateLimitError as erreur:
|
|
143
|
+
attendre(erreur.retry_after or 5)
|
|
144
|
+
except SenndoError as erreur:
|
|
145
|
+
journaliser("échec senndo", erreur)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`erreur.code` est **stable** ; `erreur.api_message` est un libellé humain qui évolue. Ne branchez
|
|
149
|
+
jamais sur le second.
|
|
150
|
+
|
|
151
|
+
Le code d'échec d'un message livré-puis-refusé est en union **ouverte** : senndo ajoute des valeurs,
|
|
152
|
+
n'en retire pas.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
message = senndo.get_message(identifiant_du_message)
|
|
156
|
+
code = message.get("failureCode")
|
|
157
|
+
|
|
158
|
+
if code is not None and code not in KNOWN_FAILURE_CODES:
|
|
159
|
+
journaliser("code plus récent que ce SDK", code)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Les montants sont des chaînes décimales
|
|
165
|
+
|
|
166
|
+
Un `NUMERIC(18,6)` passé par un flottant perd des unités sur les longues traînes, et un prix
|
|
167
|
+
unitaire sub-centime arrondi à deux décimales devient zéro.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
solde = senndo.get_balance({"currency": "EUR"})
|
|
171
|
+
disponible = Decimal(solde["balanceUsd"])
|
|
172
|
+
|
|
173
|
+
journal = senndo.list_ledger({"pageSize": 100})
|
|
174
|
+
mouvement_net = sum(
|
|
175
|
+
(Decimal(ligne["amountUsd"]) for ligne in journal["rows"]),
|
|
176
|
+
Decimal("0"),
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Le SDK décode les réponses avec `parse_float=Decimal` : aucun nombre à virgule ne traverse un
|
|
181
|
+
`float`.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Retentatives : ce qui est rejoué, et ce qui ne l'est jamais
|
|
186
|
+
|
|
187
|
+
Le SDK retente **uniquement** ce qui peut l'être sans conséquence :
|
|
188
|
+
|
|
189
|
+
| Appel | Retenté ? |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `GET`, `DELETE` | oui — sur échec de transport, 429, 5xx |
|
|
192
|
+
| `send_message` (porte une clé d'idempotence) | oui |
|
|
193
|
+
| `create_webhook`, `estimate_message`, `revoke_webhook` | **jamais** |
|
|
194
|
+
| tout `4xx` autre que 429 | jamais |
|
|
195
|
+
|
|
196
|
+
`create_webhook` crée une ressource à chaque exécution : une retentative aveugle produirait deux
|
|
197
|
+
endpoints, donc deux livraisons pour chaque événement.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
options = RequestOptions(timeout=10.0, max_retries=0)
|
|
201
|
+
senndo.send_message(
|
|
202
|
+
{
|
|
203
|
+
"channel": "sms",
|
|
204
|
+
"to": "+5511998877665",
|
|
205
|
+
"text": "Ping.",
|
|
206
|
+
"idempotencyKey": f"ping-{tentative}",
|
|
207
|
+
},
|
|
208
|
+
options,
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Téléverser un média
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
fichier = senndo.upload_media(
|
|
218
|
+
MultipartUpload(file=octets, file_name="facture.pdf", content_type="application/pdf")
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
senndo.send_message(
|
|
222
|
+
{
|
|
223
|
+
"channel": "whatsapp_cloud",
|
|
224
|
+
"to": "+33612345678",
|
|
225
|
+
"text": "Votre facture.",
|
|
226
|
+
"media": {"ref": fichier["ref"]},
|
|
227
|
+
"idempotencyKey": f"facture-{commande['reference']}",
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Brancher votre propre client HTTP
|
|
235
|
+
|
|
236
|
+
Le transport par défaut est `urllib.request` — zéro dépendance. Un projet qui a déjà `httpx`,
|
|
237
|
+
`requests`, un proxy d'entreprise ou du mTLS injecte le sien, et garde la validation, les erreurs
|
|
238
|
+
typées et la politique de retentative.
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
def transport_maison(requete: HttpRequest) -> HttpResponse:
|
|
242
|
+
reponse = appeler_mon_client(
|
|
243
|
+
requete.method, requete.url, dict(requete.headers), requete.body, requete.timeout
|
|
244
|
+
)
|
|
245
|
+
return HttpResponse(status=reponse.code, headers=reponse.entetes, body=reponse.texte)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
senndo = SenndoClient(api_key=cle_api, transport=transport_maison)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Le transport doit lever `TimeoutError` sur dépassement de délai et `OSError` sur échec de transport,
|
|
252
|
+
et **ne jamais lever** sur un statut d'erreur HTTP — sinon le code stable de l'enveloppe est perdu.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## La clé API ne s'imprime pas
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
journaliser(repr(senndo)) # SenndoClient(base_url='…', api_key='sk_live_…32 caractères masqués')
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Il n'existe aucun accesseur qui rende la clé en clair.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Webhooks
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
endpoint = senndo.create_webhook(
|
|
270
|
+
{
|
|
271
|
+
"name": "Production",
|
|
272
|
+
"url": "https://exemple.test/senndo",
|
|
273
|
+
"events": ["message.sent", "message.failed"],
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
conserver_le_secret(endpoint["secret"])
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Le secret n'est lisible **qu'à la création**. Il signe chaque livraison : vérifiez la signature
|
|
281
|
+
avant de faire quoi que ce soit du corps.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Licence
|
|
286
|
+
|
|
287
|
+
MIT. Voir [CHANGELOG.md](CHANGELOG.md) pour les changements de version.
|
senndo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# senndo — SDK Python officiel
|
|
2
|
+
|
|
3
|
+
Messagerie multicanale et vérification : SMS, WhatsApp, e-mail, voix, OTP. Une seule API, un seul
|
|
4
|
+
solde, un verdict de livraison par message.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uv add senndo
|
|
8
|
+
pip install senndo
|
|
9
|
+
poetry add senndo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Python ≥ 3.11. **Aucune dépendance d'exécution.**
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Premier envoi
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
senndo = SenndoClient(api_key=cle_api)
|
|
20
|
+
|
|
21
|
+
envoi = senndo.send_message(
|
|
22
|
+
{
|
|
23
|
+
"channel": "sms",
|
|
24
|
+
"to": "+33612345678",
|
|
25
|
+
"text": "Votre code de connexion est 4821.",
|
|
26
|
+
"idempotencyKey": f"connexion-{utilisateur_id}",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
print(envoi["id"], envoi["status"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`send_message` rend l'état **au moment de l'acceptation**, pas le verdict final. Un `sent` dit que
|
|
34
|
+
l'opérateur a pris le message ; il ne dit pas qu'il est arrivé. Le verdict se lit sur un webhook, ou
|
|
35
|
+
en relisant le message.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
message = senndo.get_message(identifiant_du_message)
|
|
39
|
+
|
|
40
|
+
if message["status"] == "failed":
|
|
41
|
+
journaliser("échec", message.get("failureCode"))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## La clé d'idempotence : le SDK n'en fabrique pas à votre place
|
|
47
|
+
|
|
48
|
+
`idempotencyKey` est **obligatoire** sur tout envoi, et c'est délibéré. Rejouer la même clé renvoie
|
|
49
|
+
le message déjà créé — sans jamais redébiter le compte.
|
|
50
|
+
|
|
51
|
+
Le SDK **n'en pose jamais une pour vous**. Une clé inventée au moment de l'appel serait perdue si le
|
|
52
|
+
processus meurt entre l'envoi et la réponse : exactement le cas où l'idempotence sert. La bonne clé
|
|
53
|
+
vient de votre domaine.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# CORRECT : la clé survit à un redémarrage, parce qu'elle vient de votre base.
|
|
57
|
+
senndo.send_message(
|
|
58
|
+
{
|
|
59
|
+
"channel": "whatsapp_cloud",
|
|
60
|
+
"to": commande["telephone"],
|
|
61
|
+
"text": "Votre commande est prête.",
|
|
62
|
+
"idempotencyKey": f"commande-{commande['reference']}-prete",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`new_idempotency_key()` existe pour les cas où il n'y a **vraiment** rien à dériver — un envoi
|
|
68
|
+
manuel depuis un script, un test :
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
senndo.send_message(
|
|
72
|
+
{
|
|
73
|
+
"channel": "sms",
|
|
74
|
+
"to": "+15551234567",
|
|
75
|
+
"text": "Essai.",
|
|
76
|
+
"idempotencyKey": new_idempotency_key("essai-"),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Les erreurs se branchent sur une classe, jamais sur un message
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
try:
|
|
87
|
+
senndo.send_message(
|
|
88
|
+
{
|
|
89
|
+
"channel": "sms",
|
|
90
|
+
"to": "+22507000000",
|
|
91
|
+
"text": "Bonjour.",
|
|
92
|
+
"idempotencyKey": f"bienvenue-{utilisateur_id}",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
except SenndoInsufficientFundsError:
|
|
96
|
+
recharger_le_compte()
|
|
97
|
+
except SenndoValidationError as erreur:
|
|
98
|
+
journaliser("appel à corriger", erreur.code, erreur.api_message)
|
|
99
|
+
except SenndoRateLimitError as erreur:
|
|
100
|
+
attendre(erreur.retry_after or 5)
|
|
101
|
+
except SenndoError as erreur:
|
|
102
|
+
journaliser("échec senndo", erreur)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`erreur.code` est **stable** ; `erreur.api_message` est un libellé humain qui évolue. Ne branchez
|
|
106
|
+
jamais sur le second.
|
|
107
|
+
|
|
108
|
+
Le code d'échec d'un message livré-puis-refusé est en union **ouverte** : senndo ajoute des valeurs,
|
|
109
|
+
n'en retire pas.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
message = senndo.get_message(identifiant_du_message)
|
|
113
|
+
code = message.get("failureCode")
|
|
114
|
+
|
|
115
|
+
if code is not None and code not in KNOWN_FAILURE_CODES:
|
|
116
|
+
journaliser("code plus récent que ce SDK", code)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Les montants sont des chaînes décimales
|
|
122
|
+
|
|
123
|
+
Un `NUMERIC(18,6)` passé par un flottant perd des unités sur les longues traînes, et un prix
|
|
124
|
+
unitaire sub-centime arrondi à deux décimales devient zéro.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
solde = senndo.get_balance({"currency": "EUR"})
|
|
128
|
+
disponible = Decimal(solde["balanceUsd"])
|
|
129
|
+
|
|
130
|
+
journal = senndo.list_ledger({"pageSize": 100})
|
|
131
|
+
mouvement_net = sum(
|
|
132
|
+
(Decimal(ligne["amountUsd"]) for ligne in journal["rows"]),
|
|
133
|
+
Decimal("0"),
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Le SDK décode les réponses avec `parse_float=Decimal` : aucun nombre à virgule ne traverse un
|
|
138
|
+
`float`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Retentatives : ce qui est rejoué, et ce qui ne l'est jamais
|
|
143
|
+
|
|
144
|
+
Le SDK retente **uniquement** ce qui peut l'être sans conséquence :
|
|
145
|
+
|
|
146
|
+
| Appel | Retenté ? |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `GET`, `DELETE` | oui — sur échec de transport, 429, 5xx |
|
|
149
|
+
| `send_message` (porte une clé d'idempotence) | oui |
|
|
150
|
+
| `create_webhook`, `estimate_message`, `revoke_webhook` | **jamais** |
|
|
151
|
+
| tout `4xx` autre que 429 | jamais |
|
|
152
|
+
|
|
153
|
+
`create_webhook` crée une ressource à chaque exécution : une retentative aveugle produirait deux
|
|
154
|
+
endpoints, donc deux livraisons pour chaque événement.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
options = RequestOptions(timeout=10.0, max_retries=0)
|
|
158
|
+
senndo.send_message(
|
|
159
|
+
{
|
|
160
|
+
"channel": "sms",
|
|
161
|
+
"to": "+5511998877665",
|
|
162
|
+
"text": "Ping.",
|
|
163
|
+
"idempotencyKey": f"ping-{tentative}",
|
|
164
|
+
},
|
|
165
|
+
options,
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Téléverser un média
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
fichier = senndo.upload_media(
|
|
175
|
+
MultipartUpload(file=octets, file_name="facture.pdf", content_type="application/pdf")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
senndo.send_message(
|
|
179
|
+
{
|
|
180
|
+
"channel": "whatsapp_cloud",
|
|
181
|
+
"to": "+33612345678",
|
|
182
|
+
"text": "Votre facture.",
|
|
183
|
+
"media": {"ref": fichier["ref"]},
|
|
184
|
+
"idempotencyKey": f"facture-{commande['reference']}",
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Brancher votre propre client HTTP
|
|
192
|
+
|
|
193
|
+
Le transport par défaut est `urllib.request` — zéro dépendance. Un projet qui a déjà `httpx`,
|
|
194
|
+
`requests`, un proxy d'entreprise ou du mTLS injecte le sien, et garde la validation, les erreurs
|
|
195
|
+
typées et la politique de retentative.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
def transport_maison(requete: HttpRequest) -> HttpResponse:
|
|
199
|
+
reponse = appeler_mon_client(
|
|
200
|
+
requete.method, requete.url, dict(requete.headers), requete.body, requete.timeout
|
|
201
|
+
)
|
|
202
|
+
return HttpResponse(status=reponse.code, headers=reponse.entetes, body=reponse.texte)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
senndo = SenndoClient(api_key=cle_api, transport=transport_maison)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Le transport doit lever `TimeoutError` sur dépassement de délai et `OSError` sur échec de transport,
|
|
209
|
+
et **ne jamais lever** sur un statut d'erreur HTTP — sinon le code stable de l'enveloppe est perdu.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## La clé API ne s'imprime pas
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
journaliser(repr(senndo)) # SenndoClient(base_url='…', api_key='sk_live_…32 caractères masqués')
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Il n'existe aucun accesseur qui rende la clé en clair.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Webhooks
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
endpoint = senndo.create_webhook(
|
|
227
|
+
{
|
|
228
|
+
"name": "Production",
|
|
229
|
+
"url": "https://exemple.test/senndo",
|
|
230
|
+
"events": ["message.sent", "message.failed"],
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
conserver_le_secret(endpoint["secret"])
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Le secret n'est lisible **qu'à la création**. Il signe chaque livraison : vérifiez la signature
|
|
238
|
+
avant de faire quoi que ce soit du corps.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Licence
|
|
243
|
+
|
|
244
|
+
MIT. Voir [CHANGELOG.md](CHANGELOG.md) pour les changements de version.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "senndo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the senndo messaging and verification API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["senndo", "sms", "whatsapp", "email", "voice", "otp", "messaging", "cpaas"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Communications",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# AUCUNE DÉPENDANCE D'EXÉCUTION, ET C'EST UNE DÉCISION.
|
|
26
|
+
#
|
|
27
|
+
# `httpx` aurait donné HTTP/2, le partage de connexion et un client asynchrone gratuit. Il aurait
|
|
28
|
+
# aussi imposé sa version — et ses transitives — à chaque projet installant `senndo`, pour un SDK
|
|
29
|
+
# dont l'appel typique est un envoi unitaire depuis un worker : la connexion persistante n'y sert à
|
|
30
|
+
# rien, et le conflit de versions, lui, se paie chez le client. `urllib.request` couvre exactement
|
|
31
|
+
# ce dont le transport a besoin (verbe, en-têtes, corps, délai, code de statut), et un projet qui
|
|
32
|
+
# veut `httpx` ou `requests` l'injecte : le transport est un paramètre du client, pas un choix
|
|
33
|
+
# imposé par le paquet.
|
|
34
|
+
dependencies = []
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://senndo.com"
|
|
38
|
+
Documentation = "https://senndo.com/developpeurs"
|
|
39
|
+
Changelog = "https://github.com/senndo/senndo-python/blob/main/CHANGELOG.md"
|
|
40
|
+
Source = "https://github.com/senndo/senndo-python"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/senndo"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/senndo", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
47
|
+
|
|
48
|
+
[tool.mypy]
|
|
49
|
+
strict = true
|
|
50
|
+
python_version = "3.11"
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|