kaneme 1.16.1__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.
- kaneme-1.16.1/PKG-INFO +96 -0
- kaneme-1.16.1/README.md +78 -0
- kaneme-1.16.1/pyproject.toml +31 -0
- kaneme-1.16.1/setup.cfg +4 -0
- kaneme-1.16.1/src/kaneme/__init__.py +35 -0
- kaneme-1.16.1/src/kaneme/client.py +358 -0
- kaneme-1.16.1/src/kaneme/contract.json +2609 -0
- kaneme-1.16.1/src/kaneme/py.typed +1 -0
- kaneme-1.16.1/src/kaneme/types.py +496 -0
- kaneme-1.16.1/src/kaneme.egg-info/PKG-INFO +96 -0
- kaneme-1.16.1/src/kaneme.egg-info/SOURCES.txt +12 -0
- kaneme-1.16.1/src/kaneme.egg-info/dependency_links.txt +1 -0
- kaneme-1.16.1/src/kaneme.egg-info/top_level.txt +1 -0
- kaneme-1.16.1/tests/test_client.py +318 -0
kaneme-1.16.1/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaneme
|
|
3
|
+
Version: 1.16.1
|
|
4
|
+
Summary: SDK Python officiel pour l'API d'identité écrite Kaneme
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://kaneme.com
|
|
7
|
+
Project-URL: Documentation, https://kaneme.com/developers
|
|
8
|
+
Keywords: kaneme,writing,voice,authorship,api
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# kaneme
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from kaneme import KanemeClient
|
|
23
|
+
|
|
24
|
+
kaneme = KanemeClient(api_key="kaneme_…")
|
|
25
|
+
verdict = kaneme.verify_authorship(text="…", question="q1")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Sur Q2, la réponse contient aussi `explanation` avec les écarts observés de
|
|
29
|
+
`cadence`, `registre`, `structure` et `lexique`. Ils éclairent le verdict sans
|
|
30
|
+
prétendre décomposer la probabilité produite par la couche profonde :
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
q2 = kaneme.verify_authorship(text="…", question="q2", profile_id=profile_id)
|
|
34
|
+
for factor in q2.get("explanation", {}).get("factors", []):
|
|
35
|
+
print(factor["label"], factor["status"], factor["detail"])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Le SDK n’ajoute aucune dépendance. Les méthodes d’écriture acceptent
|
|
39
|
+
`idempotency_key`. Les erreurs lèvent `KanemeAPIError`.
|
|
40
|
+
|
|
41
|
+
## Sécurité du transport
|
|
42
|
+
|
|
43
|
+
Depuis la version 1.16.1, le client impose HTTPS hors développement local,
|
|
44
|
+
refuse toute redirection vers une autre origine et coupe les réponses trop
|
|
45
|
+
volumineuses. Ces protections empêchent qu’une redirection malveillante reçoive
|
|
46
|
+
la clé API et qu’une réponse incontrôlée épuise la mémoire du processus :
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
kaneme = KanemeClient(
|
|
50
|
+
api_key="kaneme_…",
|
|
51
|
+
timeout=15,
|
|
52
|
+
max_response_bytes=4 * 1024 * 1024,
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
La limite par défaut est de 8 Mio (64 Mio au maximum). Une redirection refusée
|
|
57
|
+
lève `KanemeRedirectError` ; un dépassement lève `KanemeResponseTooLarge`.
|
|
58
|
+
|
|
59
|
+
## Cadence et identifiant de requête
|
|
60
|
+
|
|
61
|
+
Chaque réponse — succès compris — porte la cadence appliquée. `on_response` la
|
|
62
|
+
donne, sur les appels qui marchent comme sur les refus :
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def tracer(meta):
|
|
66
|
+
if meta.rate_limit.remaining is not None and meta.rate_limit.remaining < 5:
|
|
67
|
+
time.sleep(1) # ralentir AVANT le refus
|
|
68
|
+
print(meta.request_id) # à journaliser : c'est ce qu'on te demandera
|
|
69
|
+
|
|
70
|
+
kaneme = KanemeClient(api_key="kaneme_…", on_response=tracer)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`Retry-After` ne part que sur un 429, et se lit alors sur l’erreur :
|
|
74
|
+
`error.metadata.retry_after`. Sur un succès il vaut `None` — un ordre d’attendre
|
|
75
|
+
n’a pas de sens sur un appel qui a réussi.
|
|
76
|
+
|
|
77
|
+
## Certificats et cycle de vie
|
|
78
|
+
|
|
79
|
+
Une clé API dotée explicitement de `voice:certify` peut préparer puis publier
|
|
80
|
+
un certificat. Les liens de page, JSON et badge sont utilisables sans ouvrir le
|
|
81
|
+
compte Kaneme :
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
preparation = kaneme.prepare_certificate(document_id, display_name="Alice")
|
|
85
|
+
# En OAuth, ouvrir preparation["approvalUrl"] et attendre la validation humaine.
|
|
86
|
+
certificate = kaneme.publish_certificate(
|
|
87
|
+
preparation["approvalId"],
|
|
88
|
+
idempotency_key=f"certificate-{preparation['approvalId']}",
|
|
89
|
+
)
|
|
90
|
+
print(certificate["certificateUrl"], certificate["verificationUrl"], certificate["badgeSvgUrl"])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`list_trash`, `list_project_contexts` et `manage_lifecycle` couvrent la
|
|
94
|
+
corbeille, la restauration, l’archivage, l’expiration et la révocation. Il n’y a
|
|
95
|
+
aucune purge définitive dans le SDK ; utilisez `dry_run=True` avant une mutation
|
|
96
|
+
sensible et limitez chaque lot à 10 éléments.
|
kaneme-1.16.1/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# kaneme
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from kaneme import KanemeClient
|
|
5
|
+
|
|
6
|
+
kaneme = KanemeClient(api_key="kaneme_…")
|
|
7
|
+
verdict = kaneme.verify_authorship(text="…", question="q1")
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Sur Q2, la réponse contient aussi `explanation` avec les écarts observés de
|
|
11
|
+
`cadence`, `registre`, `structure` et `lexique`. Ils éclairent le verdict sans
|
|
12
|
+
prétendre décomposer la probabilité produite par la couche profonde :
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
q2 = kaneme.verify_authorship(text="…", question="q2", profile_id=profile_id)
|
|
16
|
+
for factor in q2.get("explanation", {}).get("factors", []):
|
|
17
|
+
print(factor["label"], factor["status"], factor["detail"])
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Le SDK n’ajoute aucune dépendance. Les méthodes d’écriture acceptent
|
|
21
|
+
`idempotency_key`. Les erreurs lèvent `KanemeAPIError`.
|
|
22
|
+
|
|
23
|
+
## Sécurité du transport
|
|
24
|
+
|
|
25
|
+
Depuis la version 1.16.1, le client impose HTTPS hors développement local,
|
|
26
|
+
refuse toute redirection vers une autre origine et coupe les réponses trop
|
|
27
|
+
volumineuses. Ces protections empêchent qu’une redirection malveillante reçoive
|
|
28
|
+
la clé API et qu’une réponse incontrôlée épuise la mémoire du processus :
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
kaneme = KanemeClient(
|
|
32
|
+
api_key="kaneme_…",
|
|
33
|
+
timeout=15,
|
|
34
|
+
max_response_bytes=4 * 1024 * 1024,
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
La limite par défaut est de 8 Mio (64 Mio au maximum). Une redirection refusée
|
|
39
|
+
lève `KanemeRedirectError` ; un dépassement lève `KanemeResponseTooLarge`.
|
|
40
|
+
|
|
41
|
+
## Cadence et identifiant de requête
|
|
42
|
+
|
|
43
|
+
Chaque réponse — succès compris — porte la cadence appliquée. `on_response` la
|
|
44
|
+
donne, sur les appels qui marchent comme sur les refus :
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
def tracer(meta):
|
|
48
|
+
if meta.rate_limit.remaining is not None and meta.rate_limit.remaining < 5:
|
|
49
|
+
time.sleep(1) # ralentir AVANT le refus
|
|
50
|
+
print(meta.request_id) # à journaliser : c'est ce qu'on te demandera
|
|
51
|
+
|
|
52
|
+
kaneme = KanemeClient(api_key="kaneme_…", on_response=tracer)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`Retry-After` ne part que sur un 429, et se lit alors sur l’erreur :
|
|
56
|
+
`error.metadata.retry_after`. Sur un succès il vaut `None` — un ordre d’attendre
|
|
57
|
+
n’a pas de sens sur un appel qui a réussi.
|
|
58
|
+
|
|
59
|
+
## Certificats et cycle de vie
|
|
60
|
+
|
|
61
|
+
Une clé API dotée explicitement de `voice:certify` peut préparer puis publier
|
|
62
|
+
un certificat. Les liens de page, JSON et badge sont utilisables sans ouvrir le
|
|
63
|
+
compte Kaneme :
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
preparation = kaneme.prepare_certificate(document_id, display_name="Alice")
|
|
67
|
+
# En OAuth, ouvrir preparation["approvalUrl"] et attendre la validation humaine.
|
|
68
|
+
certificate = kaneme.publish_certificate(
|
|
69
|
+
preparation["approvalId"],
|
|
70
|
+
idempotency_key=f"certificate-{preparation['approvalId']}",
|
|
71
|
+
)
|
|
72
|
+
print(certificate["certificateUrl"], certificate["verificationUrl"], certificate["badgeSvgUrl"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`list_trash`, `list_project_contexts` et `manage_lifecycle` couvrent la
|
|
76
|
+
corbeille, la restauration, l’archivage, l’expiration et la révocation. Il n’y a
|
|
77
|
+
aucune purge définitive dans le SDK ; utilisez `dry_run=True` avant une mutation
|
|
78
|
+
sensible et limitez chaque lot à 10 éléments.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kaneme"
|
|
7
|
+
version = "1.16.1"
|
|
8
|
+
description = "SDK Python officiel pour l'API d'identité écrite Kaneme"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["kaneme", "writing", "voice", "authorship", "api"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://kaneme.com"
|
|
25
|
+
Documentation = "https://kaneme.com/developers"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
kaneme = ["contract.json", "py.typed"]
|
kaneme-1.16.1/setup.cfg
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
CORE_OPERATION_IDS,
|
|
3
|
+
KANEME_RATE_LIMIT_FIELDS,
|
|
4
|
+
KANEME_RESPONSE_HEADERS,
|
|
5
|
+
KANEME_RESPONSE_METADATA_FIELDS,
|
|
6
|
+
MAX_CONFIGURABLE_RESPONSE_BYTES,
|
|
7
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
8
|
+
SDK_VERSION,
|
|
9
|
+
KanemeAPIError,
|
|
10
|
+
KanemeClient,
|
|
11
|
+
KanemeRedirectError,
|
|
12
|
+
KanemeRateLimit,
|
|
13
|
+
KanemeResponseTooLarge,
|
|
14
|
+
KanemeResponseMetadata,
|
|
15
|
+
KanemeTransportError,
|
|
16
|
+
)
|
|
17
|
+
from . import types as types
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CORE_OPERATION_IDS",
|
|
21
|
+
"KANEME_RATE_LIMIT_FIELDS",
|
|
22
|
+
"KANEME_RESPONSE_HEADERS",
|
|
23
|
+
"KANEME_RESPONSE_METADATA_FIELDS",
|
|
24
|
+
"MAX_CONFIGURABLE_RESPONSE_BYTES",
|
|
25
|
+
"DEFAULT_MAX_RESPONSE_BYTES",
|
|
26
|
+
"SDK_VERSION",
|
|
27
|
+
"KanemeAPIError",
|
|
28
|
+
"KanemeClient",
|
|
29
|
+
"KanemeRedirectError",
|
|
30
|
+
"KanemeRateLimit",
|
|
31
|
+
"KanemeResponseTooLarge",
|
|
32
|
+
"KanemeResponseMetadata",
|
|
33
|
+
"KanemeTransportError",
|
|
34
|
+
"types",
|
|
35
|
+
]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Client stdlib pour le contrat Kaneme.
|
|
2
|
+
|
|
3
|
+
⚠️ LA VERSION SUIT LE CONTRAT, PAS SON PROPRE RYTHME. Elle est restée à 1.5.0
|
|
4
|
+
jusqu'au 22/08 pendant que le contrat passait à 1.9.0 — et pendant que ce client
|
|
5
|
+
implémentait déjà la 1.8 (`X-Kaneme-Client`). Le numéro part dans cet en-tête à
|
|
6
|
+
chaque appel : faux, il fausse aussi ce que le serveur croit savoir des clients
|
|
7
|
+
qui l'appellent. Rien ne le comparait au contrat ; `sdk-contract.test.ts` le fait
|
|
8
|
+
maintenant, en LISANT ce fichier et `pyproject.toml`.
|
|
9
|
+
|
|
10
|
+
🔴 CE CLIENT JETAIT LES EN-TÊTES (corrigé le 22/08). Il rendait le corps JSON et
|
|
11
|
+
rien d'autre : ni `X-RateLimit-*`, ni `Retry-After`, ni `X-Request-Id`. Sur ce
|
|
12
|
+
point il était resté au contrat 1.5, où la cadence n'était qu'un nombre écrit
|
|
13
|
+
dans la documentation. La conséquence est le contraire de ce que le contrat
|
|
14
|
+
demande : la seule façon de se cadencer était d'attendre le 429, donc de
|
|
15
|
+
PROVOQUER le refus qu'on veut éviter — et l'appelant refusé n'avait même pas la
|
|
16
|
+
durée à attendre. Il rend maintenant la même enveloppe que le SDK TypeScript,
|
|
17
|
+
champ pour champ (`KanemeResponseMetadata`), et `sdk-contract.test.ts` refuse
|
|
18
|
+
qu'un des deux dérive de l'autre.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Callable, Dict, Optional, cast
|
|
25
|
+
from urllib.error import HTTPError
|
|
26
|
+
from urllib.parse import quote, urlencode, urljoin, urlsplit
|
|
27
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
28
|
+
|
|
29
|
+
from .types import (
|
|
30
|
+
KanemeAddVoiceAssetResult,
|
|
31
|
+
KanemeAuthorshipResult,
|
|
32
|
+
KanemeBeliefListResult,
|
|
33
|
+
KanemeCertificatePreparationResult,
|
|
34
|
+
KanemeCertifyResult,
|
|
35
|
+
KanemeComparisonResult,
|
|
36
|
+
KanemeCreateVoiceResult,
|
|
37
|
+
KanemeGenerateResult,
|
|
38
|
+
KanemeImproveResult,
|
|
39
|
+
KanemeLifecycleResult,
|
|
40
|
+
KanemeProjectContextListResult,
|
|
41
|
+
KanemeScoreResult,
|
|
42
|
+
KanemeSignalResult,
|
|
43
|
+
KanemeSubvoiceListResult,
|
|
44
|
+
KanemeTrashListResult,
|
|
45
|
+
KanemeVoiceAssetListResult,
|
|
46
|
+
KanemeVoiceListResult,
|
|
47
|
+
KanemeVoiceResult,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
SDK_VERSION = "1.16.1"
|
|
51
|
+
DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
52
|
+
MAX_CONFIGURABLE_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
53
|
+
CORE_OPERATION_IDS = (
|
|
54
|
+
"listVoices", "getVoice", "createVoice", "listVoiceAssets", "addVoiceAsset",
|
|
55
|
+
"listBeliefs", "listSubvoices", "scoreText", "verifyAuthorship", "compareTexts",
|
|
56
|
+
"certifyDocument", "prepareCertificate", "publishCertificate",
|
|
57
|
+
"listProjectContexts", "listTrash", "manageLifecycle",
|
|
58
|
+
"generateInVoice", "improveInVoice", "recordSignal",
|
|
59
|
+
)
|
|
60
|
+
KANEME_RATE_LIMIT_FIELDS = ("limit", "remaining", "window")
|
|
61
|
+
KANEME_RESPONSE_METADATA_FIELDS = ("status", "request_id", "retry_after", "rate_limit")
|
|
62
|
+
KANEME_RESPONSE_HEADERS = {
|
|
63
|
+
"requestId": "X-Request-Id",
|
|
64
|
+
"retryAfter": "Retry-After",
|
|
65
|
+
"rateLimitLimit": "X-RateLimit-Limit",
|
|
66
|
+
"rateLimitRemaining": "X-RateLimit-Remaining",
|
|
67
|
+
"rateLimitWindow": "X-RateLimit-Window",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class KanemeRateLimit:
|
|
73
|
+
"""La cadence AU PASSAGE DE LA GARDE : le jeton de cet appel est déjà décompté.
|
|
74
|
+
|
|
75
|
+
⚠️ `window` reste le TEXTE de l'en-tête, comme dans le SDK TypeScript. Les
|
|
76
|
+
deux SDK décrivent le même en-tête : qu'un seul le convertisse suffirait à
|
|
77
|
+
ce qu'un intégrateur qui passe de l'un à l'autre écrive un test faux.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
limit: Optional[int]
|
|
81
|
+
remaining: Optional[int]
|
|
82
|
+
window: Optional[str]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class KanemeResponseMetadata:
|
|
87
|
+
"""L'enveloppe d'exploitation d'UNE réponse — succès compris.
|
|
88
|
+
|
|
89
|
+
`retry_after` ne part que sur un refus : sur un succès, un client bien écrit
|
|
90
|
+
obéirait à un ordre d'attendre qu'on n'a pas voulu donner. La bonne façon de
|
|
91
|
+
se cadencer est de lire `rate_limit.remaining` sur les réponses qui MARCHENT.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
status: int
|
|
95
|
+
request_id: Optional[str]
|
|
96
|
+
retry_after: Optional[str]
|
|
97
|
+
rate_limit: KanemeRateLimit
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _header_number(value: Optional[str]) -> Optional[int]:
|
|
101
|
+
if value is None or not value.strip():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return int(value.strip())
|
|
105
|
+
except ValueError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _metadata(status: int, headers: Any) -> KanemeResponseMetadata:
|
|
110
|
+
# `headers` est un `email.message.Message` des deux côtés — réponse comme
|
|
111
|
+
# HTTPError — donc sa lecture est insensible à la casse. Ça compte : ces
|
|
112
|
+
# en-têtes traversent des proxys qui les renormalisent.
|
|
113
|
+
return KanemeResponseMetadata(
|
|
114
|
+
status=status,
|
|
115
|
+
request_id=headers.get(KANEME_RESPONSE_HEADERS["requestId"]),
|
|
116
|
+
retry_after=headers.get(KANEME_RESPONSE_HEADERS["retryAfter"]),
|
|
117
|
+
rate_limit=KanemeRateLimit(
|
|
118
|
+
limit=_header_number(headers.get(KANEME_RESPONSE_HEADERS["rateLimitLimit"])),
|
|
119
|
+
remaining=_header_number(headers.get(KANEME_RESPONSE_HEADERS["rateLimitRemaining"])),
|
|
120
|
+
window=headers.get(KANEME_RESPONSE_HEADERS["rateLimitWindow"]),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class KanemeAPIError(RuntimeError):
|
|
126
|
+
def __init__(self, status: int, code: Optional[str], message: str, response: Any = None,
|
|
127
|
+
metadata: Optional[KanemeResponseMetadata] = None):
|
|
128
|
+
super().__init__(message)
|
|
129
|
+
self.status, self.code, self.response = status, code, response
|
|
130
|
+
# L'enveloppe du refus. Sur un 429, `metadata.retry_after` porte les
|
|
131
|
+
# secondes à attendre — sans elle, un client refusé ne peut que deviner,
|
|
132
|
+
# et un client qui devine réessaie trop tôt.
|
|
133
|
+
self.metadata = metadata
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class KanemeTransportError(RuntimeError):
|
|
137
|
+
"""Échec local avant qu'une réponse Kaneme fiable soit disponible."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class KanemeRedirectError(KanemeTransportError):
|
|
141
|
+
"""Redirection refusée pour ne pas divulguer la clé API à un autre origin."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class KanemeResponseTooLarge(KanemeTransportError):
|
|
145
|
+
"""Réponse interrompue avant qu'elle n'épuise la mémoire du processus."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _origin(raw_url: str) -> tuple[str, str, int]:
|
|
149
|
+
parsed = urlsplit(raw_url)
|
|
150
|
+
scheme = parsed.scheme.lower()
|
|
151
|
+
host = (parsed.hostname or "").lower()
|
|
152
|
+
port = parsed.port or (443 if scheme == "https" else 80)
|
|
153
|
+
return scheme, host, port
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _validate_base_url(raw_url: str) -> str:
|
|
157
|
+
parsed = urlsplit(raw_url)
|
|
158
|
+
host = (parsed.hostname or "").lower()
|
|
159
|
+
local = host in {"localhost", "127.0.0.1", "::1"}
|
|
160
|
+
if parsed.scheme not in ({"http", "https"} if local else {"https"}):
|
|
161
|
+
raise ValueError("Kaneme base_url must use HTTPS (HTTP is allowed only for localhost)")
|
|
162
|
+
if not host or parsed.username is not None or parsed.password is not None:
|
|
163
|
+
raise ValueError("Kaneme base_url must be an absolute URL without credentials")
|
|
164
|
+
if parsed.query or parsed.fragment:
|
|
165
|
+
raise ValueError("Kaneme base_url must not contain a query or fragment")
|
|
166
|
+
return raw_url.rstrip("/")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _SameOriginRedirectHandler(HTTPRedirectHandler):
|
|
170
|
+
"""Autorise seulement les redirections qui gardent exactement le même origin."""
|
|
171
|
+
|
|
172
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
173
|
+
target = urljoin(req.full_url, newurl)
|
|
174
|
+
if _origin(req.full_url) != _origin(target):
|
|
175
|
+
raise KanemeRedirectError(
|
|
176
|
+
"Kaneme refused a cross-origin redirect to protect the Authorization header"
|
|
177
|
+
)
|
|
178
|
+
if urlsplit(req.full_url).scheme == "https" and urlsplit(target).scheme != "https":
|
|
179
|
+
raise KanemeRedirectError("Kaneme refused an HTTPS downgrade redirect")
|
|
180
|
+
return super().redirect_request(req, fp, code, msg, headers, target)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Nom volontairement patchable dans les tests, comme l'ancien `urlopen`.
|
|
184
|
+
urlopen = build_opener(_SameOriginRedirectHandler()).open
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_bounded(response: Any, max_bytes: int) -> bytes:
|
|
188
|
+
content_length = response.headers.get("Content-Length")
|
|
189
|
+
if content_length:
|
|
190
|
+
try:
|
|
191
|
+
if int(content_length) > max_bytes:
|
|
192
|
+
raise KanemeResponseTooLarge(
|
|
193
|
+
f"Kaneme response exceeds the {max_bytes}-byte limit"
|
|
194
|
+
)
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
raw = response.read(max_bytes + 1)
|
|
198
|
+
if len(raw) > max_bytes:
|
|
199
|
+
raise KanemeResponseTooLarge(
|
|
200
|
+
f"Kaneme response exceeds the {max_bytes}-byte limit"
|
|
201
|
+
)
|
|
202
|
+
return raw
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class KanemeClient:
|
|
206
|
+
def __init__(self, api_key: str, base_url: str = "https://kaneme.com", timeout: float = 30.0,
|
|
207
|
+
on_response: Optional[Callable[[KanemeResponseMetadata], None]] = None,
|
|
208
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES):
|
|
209
|
+
"""`on_response` est appelé sur CHAQUE réponse, succès comme erreur.
|
|
210
|
+
|
|
211
|
+
⚠️ Il se pose sur le client, là où le SDK TypeScript l'accepte aussi par
|
|
212
|
+
appel. Ce n'est pas un oubli : ici les méthodes passent leurs `**kwargs`
|
|
213
|
+
au corps de la requête ou à sa query, donc un paramètre réservé de plus
|
|
214
|
+
deviendrait un champ envoyé au serveur le jour où une opération
|
|
215
|
+
s'appellerait comme lui.
|
|
216
|
+
"""
|
|
217
|
+
if not api_key:
|
|
218
|
+
raise ValueError("Kaneme api_key is required")
|
|
219
|
+
if timeout <= 0:
|
|
220
|
+
raise ValueError("Kaneme timeout must be greater than zero")
|
|
221
|
+
if not 1 <= max_response_bytes <= MAX_CONFIGURABLE_RESPONSE_BYTES:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"Kaneme max_response_bytes must be between 1 and {MAX_CONFIGURABLE_RESPONSE_BYTES}"
|
|
224
|
+
)
|
|
225
|
+
self.api_key = api_key
|
|
226
|
+
self.base_url = _validate_base_url(base_url)
|
|
227
|
+
self.timeout = timeout
|
|
228
|
+
self.max_response_bytes = max_response_bytes
|
|
229
|
+
self.on_response = on_response
|
|
230
|
+
|
|
231
|
+
def _emit(self, metadata: KanemeResponseMetadata) -> None:
|
|
232
|
+
if self.on_response is not None:
|
|
233
|
+
self.on_response(metadata)
|
|
234
|
+
|
|
235
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None,
|
|
236
|
+
idempotency_key: Optional[str] = None) -> Any:
|
|
237
|
+
headers = {
|
|
238
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
239
|
+
"Accept": "application/json",
|
|
240
|
+
"X-Kaneme-Client": f"kaneme-sdk-python/{SDK_VERSION}",
|
|
241
|
+
# urllib envoie sinon ``Python-urllib/x.y``. Les protections bot de
|
|
242
|
+
# Cloudflare peuvent le refuser avant même que la requête atteigne
|
|
243
|
+
# l'API. Une identité explicite et versionnée rend le SDK utilisable
|
|
244
|
+
# derrière le même edge que le client TypeScript.
|
|
245
|
+
"User-Agent": f"kaneme-sdk-python/{SDK_VERSION}",
|
|
246
|
+
}
|
|
247
|
+
data = None
|
|
248
|
+
if body is not None:
|
|
249
|
+
headers["Content-Type"] = "application/json"
|
|
250
|
+
data = json.dumps(body).encode("utf-8")
|
|
251
|
+
if idempotency_key:
|
|
252
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
253
|
+
request = Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
254
|
+
try:
|
|
255
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
256
|
+
self._emit(_metadata(response.status, response.headers))
|
|
257
|
+
raw = _read_bounded(response, self.max_response_bytes)
|
|
258
|
+
return json.loads(raw) if raw else None
|
|
259
|
+
except HTTPError as error:
|
|
260
|
+
metadata = _metadata(error.code, error.headers)
|
|
261
|
+
self._emit(metadata)
|
|
262
|
+
raw = _read_bounded(error, min(self.max_response_bytes, 1024 * 1024))
|
|
263
|
+
try:
|
|
264
|
+
payload = json.loads(raw) if raw else {}
|
|
265
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
266
|
+
payload = {}
|
|
267
|
+
raise KanemeAPIError(
|
|
268
|
+
error.code,
|
|
269
|
+
payload.get("code"),
|
|
270
|
+
payload.get("error", f"Kaneme API error {error.code}"),
|
|
271
|
+
payload,
|
|
272
|
+
metadata,
|
|
273
|
+
) from error
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _query(**params: Any) -> str:
|
|
277
|
+
values = {
|
|
278
|
+
key: str(value).lower() if isinstance(value, bool) else value
|
|
279
|
+
for key, value in params.items()
|
|
280
|
+
if value is not None
|
|
281
|
+
}
|
|
282
|
+
return "?" + urlencode(values) if values else ""
|
|
283
|
+
|
|
284
|
+
def list_voices(self) -> KanemeVoiceListResult:
|
|
285
|
+
return cast(KanemeVoiceListResult, self._request("GET", "/api/v1/voices"))
|
|
286
|
+
|
|
287
|
+
def get_voice(self, profile_id=None) -> KanemeVoiceResult:
|
|
288
|
+
return cast(KanemeVoiceResult, self._request("GET", "/api/v1/voice" + self._query(profileId=profile_id)))
|
|
289
|
+
|
|
290
|
+
def create_voice(self, *, idempotency_key=None, **input) -> KanemeCreateVoiceResult:
|
|
291
|
+
return cast(KanemeCreateVoiceResult, self._request("POST", "/api/v1/voices", input, idempotency_key))
|
|
292
|
+
|
|
293
|
+
def list_voice_assets(self, **params) -> KanemeVoiceAssetListResult:
|
|
294
|
+
return cast(KanemeVoiceAssetListResult, self._request("GET", "/api/v1/voice/assets" + self._query(**params)))
|
|
295
|
+
|
|
296
|
+
def add_voice_asset(self, *, idempotency_key=None, **input) -> KanemeAddVoiceAssetResult:
|
|
297
|
+
return cast(KanemeAddVoiceAssetResult, self._request("POST", "/api/v1/voice/assets", input, idempotency_key))
|
|
298
|
+
|
|
299
|
+
def list_beliefs(self, **params) -> KanemeBeliefListResult:
|
|
300
|
+
return cast(KanemeBeliefListResult, self._request("GET", "/api/v1/voice/beliefs" + self._query(**params)))
|
|
301
|
+
|
|
302
|
+
def list_subvoices(self, profile_id=None) -> KanemeSubvoiceListResult:
|
|
303
|
+
return cast(KanemeSubvoiceListResult, self._request("GET", "/api/v1/voice/subvoices" + self._query(profileId=profile_id)))
|
|
304
|
+
|
|
305
|
+
def score_text(self, text, profile_id=None) -> KanemeScoreResult:
|
|
306
|
+
return cast(KanemeScoreResult, self._request("POST", "/api/v1/score", {"text": text, "profileId": profile_id}))
|
|
307
|
+
|
|
308
|
+
def verify_authorship(self, text, question="q2", profile_id=None) -> KanemeAuthorshipResult:
|
|
309
|
+
return cast(KanemeAuthorshipResult, self._request("POST", "/api/v1/verify", {"text": text, "question": question, "profileId": profile_id}))
|
|
310
|
+
|
|
311
|
+
def compare_texts(self, text, reference=None, reference_profile_id=None, question="q2") -> KanemeComparisonResult:
|
|
312
|
+
return cast(KanemeComparisonResult, self._request("POST", "/api/v1/compare", {"text": text, "reference": reference, "referenceProfileId": reference_profile_id, "question": question}))
|
|
313
|
+
|
|
314
|
+
def certify_document(self, document_id, display_name=None, idempotency_key=None) -> KanemeCertifyResult:
|
|
315
|
+
return cast(KanemeCertifyResult, self._request("POST", "/api/v1/certify", {"documentId": document_id, "displayName": display_name}, idempotency_key))
|
|
316
|
+
|
|
317
|
+
def prepare_certificate(self, document_id, display_name=None) -> KanemeCertificatePreparationResult:
|
|
318
|
+
return cast(KanemeCertificatePreparationResult, self._request(
|
|
319
|
+
"POST", "/api/v1/certificates/prepare",
|
|
320
|
+
{"documentId": document_id, "displayName": display_name},
|
|
321
|
+
))
|
|
322
|
+
|
|
323
|
+
def publish_certificate(self, approval_id, idempotency_key=None) -> KanemeCertifyResult:
|
|
324
|
+
return cast(KanemeCertifyResult, self._request(
|
|
325
|
+
"POST", "/api/v1/certificates/publish",
|
|
326
|
+
{"approvalId": approval_id}, idempotency_key,
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
def list_project_contexts(self, project_id, include_archived=None, include_trashed=None) -> KanemeProjectContextListResult:
|
|
330
|
+
query = self._query(includeArchived=include_archived, includeTrashed=include_trashed)
|
|
331
|
+
path = "/api/v1/projects/" + quote(str(project_id), safe="") + "/contexts" + query
|
|
332
|
+
return cast(KanemeProjectContextListResult, self._request("GET", path))
|
|
333
|
+
|
|
334
|
+
def list_trash(self) -> KanemeTrashListResult:
|
|
335
|
+
return cast(KanemeTrashListResult, self._request("GET", "/api/v1/trash"))
|
|
336
|
+
|
|
337
|
+
def manage_lifecycle(self, items, dry_run=None, idempotency_key=None) -> KanemeLifecycleResult:
|
|
338
|
+
body = {"items": items}
|
|
339
|
+
if dry_run is not None:
|
|
340
|
+
body["dryRun"] = dry_run
|
|
341
|
+
return cast(KanemeLifecycleResult, self._request(
|
|
342
|
+
"POST", "/api/v1/lifecycle", body, idempotency_key,
|
|
343
|
+
))
|
|
344
|
+
|
|
345
|
+
def generate_in_voice(self, prompt, idempotency_key=None, **input) -> KanemeGenerateResult:
|
|
346
|
+
return cast(KanemeGenerateResult, self._request("POST", "/api/v1/generate", {"prompt": prompt, **input}, idempotency_key))
|
|
347
|
+
|
|
348
|
+
def improve_in_voice(self, text, profile_id=None, idempotency_key=None) -> KanemeImproveResult:
|
|
349
|
+
return cast(KanemeImproveResult, self._request("POST", "/api/v1/improve", {"text": text, "profileId": profile_id}, idempotency_key))
|
|
350
|
+
|
|
351
|
+
def record_signal(self, kind, *, idempotency_key=None, **input) -> KanemeSignalResult:
|
|
352
|
+
"""Fait APPRENDRE la voix : gratuit, et le seul geste qui la fasse evoluer.
|
|
353
|
+
|
|
354
|
+
Passe le `format` du canal — sans lui, la sous-voix de ce canal n apprend
|
|
355
|
+
rien. Sur un rejet, `proposed`/`kept` sont deux TEXTES : le signal est
|
|
356
|
+
derive serveur, jamais une liste de termes.
|
|
357
|
+
"""
|
|
358
|
+
return cast(KanemeSignalResult, self._request("POST", "/api/v1/signal", {"kind": kind, **input}, idempotency_key))
|