opacte 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.
opacte/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """SDK Python de l'API Opacte — attestations, vérification et, bientôt, signature.
2
+
3
+ Le contrat HTTP est publié (https://opacte.eu/api/openapi.json) ; ce SDK en est la voie recommandée.
4
+ """
5
+
6
+ from opacte.client import Attestation, Client, Compte, __version__
7
+ from opacte.erreurs import (
8
+ CleInvalide,
9
+ CompteSuspendu,
10
+ CreditsInsuffisants,
11
+ ErreurOpacte,
12
+ ErreurReseau,
13
+ Introuvable,
14
+ TropDeDemandes,
15
+ )
16
+
17
+ __all__ = [
18
+ "Attestation",
19
+ "CleInvalide",
20
+ "Client",
21
+ "Compte",
22
+ "CompteSuspendu",
23
+ "CreditsInsuffisants",
24
+ "ErreurOpacte",
25
+ "ErreurReseau",
26
+ "Introuvable",
27
+ "TropDeDemandes",
28
+ "__version__",
29
+ ]
opacte/client.py ADDED
@@ -0,0 +1,195 @@
1
+ """Client de l'API Opacte (contrat : https://opacte.eu/api/openapi.json ; 290_api_contrat.md).
2
+
3
+ from opacte import Client
4
+
5
+ client = Client() # clé lue dans OPACTE_API_KEY
6
+ print(client.compte().solde)
7
+ attestation = client.attestations.creer("facture.pdf")
8
+
9
+ Fiabilité : sur coupure, `429` ou `5xx`, l'appel est rejoué (au plus `essais` fois, en respectant `Retry-After`),
10
+ avec la même `Idempotency-Key` pour les créations : jamais de second débit.
11
+ """
12
+
13
+ import os
14
+ import time
15
+ import uuid
16
+ from collections.abc import Iterator
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, BinaryIO
20
+
21
+ import httpx
22
+
23
+ from opacte import empreintes
24
+ from opacte.erreurs import ErreurReseau, TropDeDemandes, depuis_reponse
25
+
26
+ URL = "https://opacte.eu"
27
+ VERSION_API = "1"
28
+ __version__ = "0.1.0"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Compte:
33
+ email: str
34
+ solde: int
35
+ requetes_par_minute: int
36
+ restantes: int
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Attestation:
41
+ id_attestation: str
42
+ horodatage: str
43
+ statut: str # provisoire | definitive | ancree
44
+ taille: int | None = None
45
+ email_visible: bool | None = None
46
+ # à la création seulement : le reçu provisoire, et le jeton (à conserver) qui donne accès au reçu par son URL
47
+ recu: dict[str, Any] | None = field(default=None, repr=False)
48
+ jeton_recuperation: str | None = field(default=None, repr=False)
49
+ url_recu: str | None = None
50
+ credits: int | None = None
51
+
52
+
53
+ class Client:
54
+ def __init__(
55
+ self,
56
+ cle: str | None = None,
57
+ *,
58
+ url: str = URL,
59
+ essais: int = 3,
60
+ delai: float = 30.0,
61
+ http: httpx.Client | None = None,
62
+ ):
63
+ cle = cle or os.environ.get("OPACTE_API_KEY")
64
+ if not cle:
65
+ raise ValueError("clé API absente : passez cle=… ou définissez OPACTE_API_KEY")
66
+ self._entetes = {
67
+ "Authorization": f"Bearer {cle}",
68
+ "Said-Api-Version": VERSION_API,
69
+ "User-Agent": f"opacte-python/{__version__}",
70
+ }
71
+ self._http = http or httpx.Client(base_url=url, timeout=delai)
72
+ self._essais = max(1, essais)
73
+ self.attestations = Attestations(self)
74
+
75
+ def __enter__(self) -> "Client":
76
+ return self
77
+
78
+ def __exit__(self, *_: object) -> None:
79
+ self.fermer()
80
+
81
+ def fermer(self) -> None:
82
+ self._http.close()
83
+
84
+ # --- transport -----------------------------------------------------------------------------------------
85
+
86
+ def _appeler(
87
+ self,
88
+ methode: str,
89
+ chemin: str,
90
+ *,
91
+ idempotent: bool = False,
92
+ corps: Any = None,
93
+ fichier: tuple[Path | bytes, int] | None = None,
94
+ params: dict[str, Any] | None = None,
95
+ ) -> Any:
96
+ entetes = dict(self._entetes)
97
+ if idempotent:
98
+ entetes["Idempotency-Key"] = str(uuid.uuid4()) # la même pour toutes les tentatives
99
+ for essai in range(self._essais):
100
+ try:
101
+ if fichier is not None:
102
+ source, taille = fichier
103
+ entetes.update({"Content-Type": "application/octet-stream", "Content-Length": str(taille)})
104
+ if isinstance(source, bytes):
105
+ r = self._http.request(methode, chemin, content=source, headers=entetes, params=params)
106
+ else:
107
+ with open(source, "rb") as f: # rouvert à chaque tentative : envoyé en flux
108
+ r = self._http.request(methode, chemin, content=f, headers=entetes, params=params)
109
+ else:
110
+ r = self._http.request(methode, chemin, json=corps, headers=entetes, params=params)
111
+ except httpx.TransportError as e:
112
+ if essai + 1 == self._essais:
113
+ raise ErreurReseau(e) from e
114
+ time.sleep(2**essai)
115
+ continue
116
+ if r.status_code == 429 or r.status_code >= 500:
117
+ if essai + 1 < self._essais:
118
+ time.sleep(float(r.headers.get("Retry-After") or 2**essai))
119
+ continue
120
+ if r.status_code >= 400:
121
+ try:
122
+ detail = r.json()
123
+ except ValueError:
124
+ detail = {"erreur": "inconnue"}
125
+ exc = depuis_reponse(r.status_code, dict(detail))
126
+ if isinstance(exc, TropDeDemandes):
127
+ exc.retry_after = float(r.headers.get("Retry-After") or 0)
128
+ raise exc
129
+ return r.json() if r.content else None
130
+ raise AssertionError("inatteignable")
131
+
132
+ # --- compte et vérification ------------------------------------------------------------------------------
133
+
134
+ def compte(self) -> Compte:
135
+ """L'adresse du compte, le solde de crédits (commun au site) et la marge de débit."""
136
+ c = self._appeler("GET", "/api/account")
137
+ return Compte(c["email"], c["solde"], c["limites"]["requetes_par_minute"], c["limites"]["restantes"])
138
+
139
+ def verifier(self, fichier: empreintes.Source) -> list[dict[str, Any]]:
140
+ """Attestations candidates pour un fichier reçu. Ses empreintes sont calculées ici : il ne part pas."""
141
+ taille, valeurs = empreintes.calculer(fichier)
142
+ reponse = self._appeler("POST", "/api/verifications", corps={"taille": taille, "empreintes": valeurs})
143
+ return reponse["candidates"]
144
+
145
+
146
+ class Attestations:
147
+ def __init__(self, client: Client):
148
+ self._c = client
149
+
150
+ def creer(self, fichier: str | Path | bytes | BinaryIO, *, email_visible: bool = True) -> Attestation:
151
+ """Atteste un fichier (1 crédit). Envoyé en flux, lu à la volée, jamais conservé par Opacte."""
152
+ if isinstance(fichier, (str, Path)):
153
+ source: Path | bytes = Path(fichier)
154
+ taille = source.stat().st_size
155
+ elif isinstance(fichier, bytes):
156
+ source, taille = fichier, len(fichier)
157
+ else:
158
+ source = fichier.read() # un flux ouvert ne se rejoue pas : lu une fois
159
+ taille = len(source)
160
+ a = self._c._appeler(
161
+ "POST",
162
+ "/api/attestations",
163
+ idempotent=True,
164
+ fichier=(source, taille),
165
+ params={"email_visible": "true" if email_visible else "false"},
166
+ )
167
+ return Attestation(
168
+ a["id_attestation"],
169
+ a["horodatage"],
170
+ a["statut"],
171
+ recu=a["recu"],
172
+ jeton_recuperation=a["jeton_recuperation"],
173
+ url_recu=a["url_recu"],
174
+ credits=a["credits"],
175
+ )
176
+
177
+ def lire(self, id_attestation: str) -> Attestation:
178
+ a = self._c._appeler("GET", f"/api/attestations/{id_attestation}")
179
+ return Attestation(a["id_attestation"], a["horodatage"], a["statut"], a["taille"], a["email_visible"])
180
+
181
+ def recu(self, id_attestation: str) -> dict[str, Any]:
182
+ """Le meilleur reçu signé disponible (provisoire, puis définitif avec son ancrage)."""
183
+ return self._c._appeler("GET", f"/api/attestations/{id_attestation}/receipt")
184
+
185
+ def lister(self, *, par_page: int = 100) -> Iterator[Attestation]:
186
+ """Toutes les attestations du compte, les plus récentes d'abord (pages chargées à la demande)."""
187
+ apres: str | None = None
188
+ while True:
189
+ params: dict[str, Any] = {"limite": par_page, **({"apres": apres} if apres else {})}
190
+ page = self._c._appeler("GET", "/api/attestations", params=params)
191
+ for a in page["elements"]:
192
+ yield Attestation(a["id_attestation"], a["horodatage"], a["statut"], a["taille"], a["email_visible"])
193
+ apres = page["suivant"]
194
+ if not apres:
195
+ return
opacte/empreintes.py ADDED
@@ -0,0 +1,42 @@
1
+ """Empreintes d'un fichier, calculées localement (110_formats_fc_v1.md) : le fichier ne part pas pour être vérifié.
2
+
3
+ Les trois algorithmes du socle v1, en base64url sans remplissage ; le fichier est lu par blocs, jamais en entier.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+ from typing import BinaryIO
11
+
12
+ ALGOS = {"blake2b-512": lambda: hashlib.blake2b(digest_size=64), "sha256": hashlib.sha256, "sha3-512": hashlib.sha3_512}
13
+ BLOC = 1024 * 1024
14
+
15
+ Source = str | Path | bytes | BinaryIO
16
+
17
+
18
+ def _b64u(octets: bytes) -> str:
19
+ return base64.urlsafe_b64encode(octets).rstrip(b"=").decode("ascii")
20
+
21
+
22
+ def _blocs(source: Source) -> Iterable[bytes]:
23
+ if isinstance(source, bytes):
24
+ yield source
25
+ elif isinstance(source, (str, Path)):
26
+ with open(source, "rb") as f:
27
+ while bloc := f.read(BLOC):
28
+ yield bloc
29
+ else:
30
+ while bloc := source.read(BLOC):
31
+ yield bloc
32
+
33
+
34
+ def calculer(source: Source) -> tuple[int, list[dict[str, str]]]:
35
+ """Taille et empreintes (`[{"algo", "valeur"}]`, dans l'ordre fc-v1) d'un fichier, d'octets ou d'un flux."""
36
+ contextes = {algo: fabrique() for algo, fabrique in ALGOS.items()}
37
+ taille = 0
38
+ for bloc in _blocs(source):
39
+ taille += len(bloc)
40
+ for c in contextes.values():
41
+ c.update(bloc)
42
+ return taille, [{"algo": a, "valeur": _b64u(contextes[a].digest())} for a in sorted(contextes)]
opacte/erreurs.py ADDED
@@ -0,0 +1,58 @@
1
+ """Erreurs de l'API Opacte, en exceptions typées : le code stable de l'API (`erreur`) et ses détails."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ErreurOpacte(Exception):
7
+ """Réponse d'erreur de l'API ; `code` est le champ `erreur` du contrat, `details` le reste du corps."""
8
+
9
+ def __init__(self, statut: int, code: str, details: dict[str, Any] | None = None):
10
+ super().__init__(f"{statut} {code}")
11
+ self.statut, self.code, self.details = statut, code, details or {}
12
+
13
+
14
+ class CleInvalide(ErreurOpacte):
15
+ """Clé absente, inconnue ou révoquée : en créer une dans Réglages."""
16
+
17
+
18
+ class CompteSuspendu(ErreurOpacte):
19
+ pass
20
+
21
+
22
+ class CreditsInsuffisants(ErreurOpacte):
23
+ """Solde insuffisant ; `solde` donne le solde actuel. Les crédits s'achètent sur le site."""
24
+
25
+ @property
26
+ def solde(self) -> int | None:
27
+ return self.details.get("solde")
28
+
29
+
30
+ class TropDeDemandes(ErreurOpacte):
31
+ """Limite de débit atteinte ; `retry_after` : secondes avant de réessayer (le SDK réessaie de lui-même)."""
32
+
33
+ retry_after: float = 0.0
34
+
35
+
36
+ class Introuvable(ErreurOpacte):
37
+ pass
38
+
39
+
40
+ class ErreurReseau(ErreurOpacte):
41
+ """Pas de réponse du serveur, après les nouvelles tentatives."""
42
+
43
+ def __init__(self, cause: Exception):
44
+ super().__init__(0, "reseau", {"cause": str(cause)})
45
+
46
+
47
+ _PAR_CODE = {
48
+ "cle_invalide": CleInvalide,
49
+ "compte_suspendu": CompteSuspendu,
50
+ "credits_insuffisants": CreditsInsuffisants,
51
+ "trop_de_demandes": TropDeDemandes,
52
+ }
53
+
54
+
55
+ def depuis_reponse(statut: int, corps: dict[str, Any]) -> ErreurOpacte:
56
+ code = str(corps.pop("erreur", "inconnue"))
57
+ classe = _PAR_CODE.get(code) or (Introuvable if statut == 404 else ErreurOpacte)
58
+ return classe(statut, code, corps)
opacte/py.typed ADDED
File without changes
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: opacte
3
+ Version: 0.1.0
4
+ Summary: SDK Python de l'API Opacte : attester et vérifier des documents, sans qu'ils quittent votre serveur pour être conservés.
5
+ Project-URL: Homepage, https://opacte.eu
6
+ Project-URL: Documentation, https://opacte.eu/api
7
+ Project-URL: Contrat HTTP, https://opacte.eu/api/openapi.json
8
+ Project-URL: Tutoriel, https://opacte.eu/api-tutorial
9
+ Author: Jean-Michel ARNAUD
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: attestation,bitcoin,horodatage,opacte,opentimestamps,preuve,signature électronique
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: French
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Security :: Cryptography
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: httpx>=0.27
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # opacte — SDK Python de l'API Opacte
35
+
36
+ Attestez et vérifiez des documents depuis votre logiciel, sans captcha ni code. Comme sur
37
+ [opacte.eu](https://opacte.eu), le document n'est jamais conservé : Opacte n'en garde que les empreintes, et la preuve
38
+ est ancrée dans Bitcoin.
39
+
40
+ ## Installation
41
+
42
+ ```
43
+ pip install opacte
44
+ ```
45
+
46
+ Python 3.10 ou plus. Créez un compte sur [opacte.eu](https://opacte.eu/account), puis une clé API dans **Réglages**,
47
+ et rendez-la disponible dans la variable d'environnement `OPACTE_API_KEY` (jamais dans votre code).
48
+
49
+ ## Exemple
50
+
51
+ ```python
52
+ from opacte import Client, CreditsInsuffisants
53
+
54
+ client = Client() # clé lue dans OPACTE_API_KEY
55
+ print(client.compte().solde)
56
+
57
+ # Attester un fichier : 1 crédit. Envoyé en flux, lu à la volée, jamais conservé.
58
+ attestation = client.attestations.creer("facture-2026-118.pdf", email_visible=False)
59
+ print(attestation.id_attestation, attestation.url_recu) # gardez url_recu : accès pérenne au reçu
60
+
61
+ # Vérifier un document reçu : ses empreintes sont calculées ici, le fichier ne part pas. Gratuit.
62
+ for candidate in client.verifier("document-recu.pdf"):
63
+ print(candidate["id_attestation"], candidate["horodatage"])
64
+ ```
65
+
66
+ ## Ce que fait le SDK
67
+
68
+ - **Fichiers en flux** : un fichier est envoyé par blocs, jamais chargé en entier en mémoire.
69
+ - **Nouvelles tentatives** : sur coupure, `429` ou `5xx`, l'appel est rejoué avec la même `Idempotency-Key` : jamais
70
+ de second débit.
71
+ - **Erreurs typées** : `CleInvalide`, `CreditsInsuffisants` (avec `solde`), `TropDeDemandes`, `Introuvable`…
72
+ - **Typé** : annotations complètes, vérifiables par votre éditeur ou mypy.
73
+
74
+ ## Liens
75
+
76
+ - Documentation et référence : [opacte.eu/api](https://opacte.eu/api)
77
+ - Tutoriel : [opacte.eu/api-tutorial](https://opacte.eu/api-tutorial)
78
+ - Contrat HTTP publié (OpenAPI) : [opacte.eu/api/openapi.json](https://opacte.eu/api/openapi.json) — le SDK n'en est
79
+ qu'une mise en forme : vous pouvez vérifier ce qu'il envoie.
80
+
81
+ La signature électronique par l'API, avec le document chiffré chez vous, arrive dans une prochaine version.
82
+
83
+ Licence MIT.
@@ -0,0 +1,9 @@
1
+ opacte/__init__.py,sha256=7tLO_LIvDzcloph6bLjOCyvMUmQGIzFKVdaMPuQE-2w,659
2
+ opacte/client.py,sha256=AVk29KAr2I0J1tHcN7h7V8D_jDY9WabVERUUs0a_W_4,7740
3
+ opacte/empreintes.py,sha256=j2grbnlxqTvbfonXlbonrbFXS2qCziaK7sktocZ0GFU,1464
4
+ opacte/erreurs.py,sha256=YjeNn1fdJ4K5HapEEwfwpr3EJiR_rMef9R0iWCw-ZdM,1739
5
+ opacte/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ opacte-0.1.0.dist-info/METADATA,sha256=BcPUS4_zrSWE-2IfS6d9uTZk2IeANZn2GVG3cL-hOS0,3537
7
+ opacte-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ opacte-0.1.0.dist-info/licenses/LICENSE,sha256=MwHg5Opb5TQcecAKibm8L649qPudrL1wCdnuz4PR54Q,1075
9
+ opacte-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jean-Michel ARNAUD
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.