opacte 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.
- opacte-0.1.0/.gitignore +8 -0
- opacte-0.1.0/LICENSE +21 -0
- opacte-0.1.0/PKG-INFO +83 -0
- opacte-0.1.0/README.md +50 -0
- opacte-0.1.0/pyproject.toml +54 -0
- opacte-0.1.0/src/opacte/__init__.py +29 -0
- opacte-0.1.0/src/opacte/client.py +195 -0
- opacte-0.1.0/src/opacte/empreintes.py +42 -0
- opacte-0.1.0/src/opacte/erreurs.py +58 -0
- opacte-0.1.0/src/opacte/py.typed +0 -0
- opacte-0.1.0/tests/test_client.py +87 -0
opacte-0.1.0/.gitignore
ADDED
opacte-0.1.0/LICENSE
ADDED
|
@@ -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.
|
opacte-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
opacte-0.1.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# opacte — SDK Python de l'API Opacte
|
|
2
|
+
|
|
3
|
+
Attestez et vérifiez des documents depuis votre logiciel, sans captcha ni code. Comme sur
|
|
4
|
+
[opacte.eu](https://opacte.eu), le document n'est jamais conservé : Opacte n'en garde que les empreintes, et la preuve
|
|
5
|
+
est ancrée dans Bitcoin.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install opacte
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Python 3.10 ou plus. Créez un compte sur [opacte.eu](https://opacte.eu/account), puis une clé API dans **Réglages**,
|
|
14
|
+
et rendez-la disponible dans la variable d'environnement `OPACTE_API_KEY` (jamais dans votre code).
|
|
15
|
+
|
|
16
|
+
## Exemple
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from opacte import Client, CreditsInsuffisants
|
|
20
|
+
|
|
21
|
+
client = Client() # clé lue dans OPACTE_API_KEY
|
|
22
|
+
print(client.compte().solde)
|
|
23
|
+
|
|
24
|
+
# Attester un fichier : 1 crédit. Envoyé en flux, lu à la volée, jamais conservé.
|
|
25
|
+
attestation = client.attestations.creer("facture-2026-118.pdf", email_visible=False)
|
|
26
|
+
print(attestation.id_attestation, attestation.url_recu) # gardez url_recu : accès pérenne au reçu
|
|
27
|
+
|
|
28
|
+
# Vérifier un document reçu : ses empreintes sont calculées ici, le fichier ne part pas. Gratuit.
|
|
29
|
+
for candidate in client.verifier("document-recu.pdf"):
|
|
30
|
+
print(candidate["id_attestation"], candidate["horodatage"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Ce que fait le SDK
|
|
34
|
+
|
|
35
|
+
- **Fichiers en flux** : un fichier est envoyé par blocs, jamais chargé en entier en mémoire.
|
|
36
|
+
- **Nouvelles tentatives** : sur coupure, `429` ou `5xx`, l'appel est rejoué avec la même `Idempotency-Key` : jamais
|
|
37
|
+
de second débit.
|
|
38
|
+
- **Erreurs typées** : `CleInvalide`, `CreditsInsuffisants` (avec `solde`), `TropDeDemandes`, `Introuvable`…
|
|
39
|
+
- **Typé** : annotations complètes, vérifiables par votre éditeur ou mypy.
|
|
40
|
+
|
|
41
|
+
## Liens
|
|
42
|
+
|
|
43
|
+
- Documentation et référence : [opacte.eu/api](https://opacte.eu/api)
|
|
44
|
+
- Tutoriel : [opacte.eu/api-tutorial](https://opacte.eu/api-tutorial)
|
|
45
|
+
- Contrat HTTP publié (OpenAPI) : [opacte.eu/api/openapi.json](https://opacte.eu/api/openapi.json) — le SDK n'en est
|
|
46
|
+
qu'une mise en forme : vous pouvez vérifier ce qu'il envoie.
|
|
47
|
+
|
|
48
|
+
La signature électronique par l'API, avec le document chiffré chez vous, arrive dans une prochaine version.
|
|
49
|
+
|
|
50
|
+
Licence MIT.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opacte"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SDK Python de l'API Opacte : attester et vérifier des documents, sans qu'ils quittent votre serveur pour être conservés."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Jean-Michel ARNAUD" }]
|
|
10
|
+
keywords = ["opacte", "attestation", "horodatage", "preuve", "signature électronique", "bitcoin", "opentimestamps"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Natural Language :: French",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.27"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://opacte.eu"
|
|
34
|
+
Documentation = "https://opacte.eu/api"
|
|
35
|
+
"Contrat HTTP" = "https://opacte.eu/api/openapi.json"
|
|
36
|
+
Tutoriel = "https://opacte.eu/api-tutorial"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling>=1.27"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/opacte", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
44
|
+
# le .gitignore propre au SDK (sinon hatchling remonte jusqu'à celui du dépôt privé)
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/opacte"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 120
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
@@ -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)]
|
|
@@ -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)
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Le SDK contre un serveur simulé (httpx.MockTransport) : en-têtes, flux, nouvelles tentatives, erreurs."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from opacte import CleInvalide, Client, CreditsInsuffisants, empreintes
|
|
10
|
+
|
|
11
|
+
CLE = "opk_" + "A" * 49
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def client_simule(gerer, **options) -> tuple[Client, list[httpx.Request]]:
|
|
15
|
+
vues: list[httpx.Request] = []
|
|
16
|
+
|
|
17
|
+
def transport(requete: httpx.Request) -> httpx.Response:
|
|
18
|
+
requete.read()
|
|
19
|
+
vues.append(requete)
|
|
20
|
+
return gerer(requete, len(vues))
|
|
21
|
+
|
|
22
|
+
http = httpx.Client(base_url="https://opacte.test", transport=httpx.MockTransport(transport))
|
|
23
|
+
return Client(CLE, http=http, **options), vues
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_cle_obligatoire(monkeypatch):
|
|
27
|
+
monkeypatch.delenv("OPACTE_API_KEY", raising=False)
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
Client()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_compte_et_entetes():
|
|
33
|
+
corps = {"email": "a@b.fr", "solde": 7, "limites": {"requetes_par_minute": 120, "restantes": 119}}
|
|
34
|
+
client, vues = client_simule(lambda r, n: httpx.Response(200, json=corps))
|
|
35
|
+
assert client.compte().solde == 7
|
|
36
|
+
assert vues[0].headers["Authorization"] == f"Bearer {CLE}" and vues[0].headers["Said-Api-Version"] == "1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_attestation_rejouee_avec_la_meme_cle_d_idempotence(monkeypatch, tmp_path):
|
|
40
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
41
|
+
fichier = tmp_path / "facture.pdf"
|
|
42
|
+
fichier.write_bytes(b"%PDF" * 5000)
|
|
43
|
+
cree = {
|
|
44
|
+
"id_attestation": "01J",
|
|
45
|
+
"horodatage": "2026-10-28T10:00:00Z",
|
|
46
|
+
"statut": "provisoire",
|
|
47
|
+
"recu": {},
|
|
48
|
+
"jeton_recuperation": "j",
|
|
49
|
+
"url_recu": "https://opacte.test/attestation/01J/receipt?token=j",
|
|
50
|
+
"credits": 4,
|
|
51
|
+
}
|
|
52
|
+
client, vues = client_simule(lambda r, n: httpx.Response(503) if n == 1 else httpx.Response(201, json=cree))
|
|
53
|
+
a = client.attestations.creer(fichier, email_visible=False)
|
|
54
|
+
assert a.id_attestation == "01J" and a.credits == 4
|
|
55
|
+
assert len(vues) == 2 and vues[0].headers["Idempotency-Key"] == vues[1].headers["Idempotency-Key"]
|
|
56
|
+
assert vues[1].content == fichier.read_bytes() and vues[1].headers["Content-Length"] == "20000"
|
|
57
|
+
assert vues[1].url.params["email_visible"] == "false"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_erreurs_typees():
|
|
61
|
+
client, _ = client_simule(lambda r, n: httpx.Response(402, json={"erreur": "credits_insuffisants", "solde": 0}))
|
|
62
|
+
with pytest.raises(CreditsInsuffisants) as e:
|
|
63
|
+
client.attestations.creer(b"x" * 9000)
|
|
64
|
+
assert e.value.solde == 0
|
|
65
|
+
client, _ = client_simule(lambda r, n: httpx.Response(401, json={"erreur": "cle_invalide"}))
|
|
66
|
+
with pytest.raises(CleInvalide):
|
|
67
|
+
client.compte()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_verification_sans_envoyer_le_fichier():
|
|
71
|
+
contenu = b"contrat signe" * 1000
|
|
72
|
+
client, vues = client_simule(lambda r, n: httpx.Response(200, json={"candidates": []}))
|
|
73
|
+
assert client.verifier(contenu) == []
|
|
74
|
+
envoye = json.loads(vues[0].content)
|
|
75
|
+
assert envoye["taille"] == len(contenu) and contenu not in vues[0].content
|
|
76
|
+
valeurs = {e["algo"]: e["valeur"] for e in envoye["empreintes"]}
|
|
77
|
+
assert list(valeurs) == ["blake2b-512", "sha256", "sha3-512"]
|
|
78
|
+
assert valeurs["sha256"] == empreintes._b64u(hashlib.sha256(contenu).digest())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_pagination():
|
|
82
|
+
def element(id_a: str) -> dict:
|
|
83
|
+
return {"id_attestation": id_a, "horodatage": "t", "statut": "ancree", "taille": 1, "email_visible": True}
|
|
84
|
+
|
|
85
|
+
pages = {None: {"elements": [element("3")], "suivant": "3"}, "3": {"elements": [element("2")], "suivant": None}}
|
|
86
|
+
client, _ = client_simule(lambda r, n: httpx.Response(200, json=pages[r.url.params.get("apres")]))
|
|
87
|
+
assert [a.id_attestation for a in client.attestations.lister()] == ["3", "2"]
|