electricore-client 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.
- electricore_client/__init__.py +53 -0
- electricore_client/arrow.py +128 -0
- electricore_client/client.py +180 -0
- electricore_client/exceptions.py +31 -0
- electricore_client/headers.py +44 -0
- electricore_client/models/__init__.py +40 -0
- electricore_client/models/chronologie.py +110 -0
- electricore_client/models/meta_periodes.py +88 -0
- electricore_client/models/turpe_variable.py +57 -0
- electricore_client/py.typed +0 -0
- electricore_client/streaming.py +113 -0
- electricore_client/transport.py +90 -0
- electricore_client-0.1.0.dist-info/METADATA +71 -0
- electricore_client-0.1.0.dist-info/RECORD +15 -0
- electricore_client-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""electricore-client — client léger vers l'API facturiste electricore.
|
|
2
|
+
|
|
3
|
+
Distribué séparément du moteur, dépendances de base **httpx + pydantic
|
|
4
|
+
uniquement** : ce paquet n'importe jamais polars/duckdb/fastapi au top-level
|
|
5
|
+
(invariant prouvé par le test de pureté). Le client Arrow historique
|
|
6
|
+
(DataFrames polars) vit dans le sous-module `electricore_client.arrow`, derrière
|
|
7
|
+
l'extra `[arrow]`, et n'est *pas* importé ici.
|
|
8
|
+
|
|
9
|
+
Lecture seule sur electricore (ADR-0012). Voir ADR-0043 pour la conception.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .client import ElectricoreClient
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
ContractVersionError,
|
|
17
|
+
ElectricoreClientError,
|
|
18
|
+
IngestionEnCours,
|
|
19
|
+
)
|
|
20
|
+
from .headers import EnTetesMeta
|
|
21
|
+
from .models import (
|
|
22
|
+
LigneChronologie,
|
|
23
|
+
LigneEvenement,
|
|
24
|
+
LignePeriodeEnergie,
|
|
25
|
+
LigneReleve,
|
|
26
|
+
LigneTurpeVariable,
|
|
27
|
+
ObjetReleve,
|
|
28
|
+
PeriodeMeta,
|
|
29
|
+
ResultatTurpeVariable,
|
|
30
|
+
TurpeVariableRequest,
|
|
31
|
+
)
|
|
32
|
+
from .streaming import JsonlStream
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ElectricoreClient",
|
|
38
|
+
"ElectricoreClientError",
|
|
39
|
+
"IngestionEnCours",
|
|
40
|
+
"ContractVersionError",
|
|
41
|
+
"EnTetesMeta",
|
|
42
|
+
"JsonlStream",
|
|
43
|
+
"PeriodeMeta",
|
|
44
|
+
"ObjetReleve",
|
|
45
|
+
"LigneChronologie",
|
|
46
|
+
"LigneEvenement",
|
|
47
|
+
"LigneReleve",
|
|
48
|
+
"LignePeriodeEnergie",
|
|
49
|
+
"LigneTurpeVariable",
|
|
50
|
+
"TurpeVariableRequest",
|
|
51
|
+
"ResultatTurpeVariable",
|
|
52
|
+
"__version__",
|
|
53
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Client Arrow historique : endpoints structurés en `pl.DataFrame` (extra `[arrow]`).
|
|
2
|
+
|
|
3
|
+
Ce sous-module **n'est jamais importé au top-level** de `electricore_client` :
|
|
4
|
+
il porte le seul code qui dépend de polars, et l'importe **paresseusement** (à
|
|
5
|
+
l'appel d'une méthode). La base du paquet reste donc polars-free — l'invariant
|
|
6
|
+
prouvé par le test de pureté tient même quand `[arrow]` n'est pas installé.
|
|
7
|
+
|
|
8
|
+
Endpoints Arrow IPC (`/flux/{table}.arrow`, `/releves.arrow`, `/facturation/
|
|
9
|
+
detail.arrow`, `/taxes/{accise,cta}/detail.arrow`) — pour les notebooks distants
|
|
10
|
+
qui n'ont pas la base DuckDB en local (ADR-0009). Lecture seule (ADR-0012).
|
|
11
|
+
|
|
12
|
+
Installer avec l'extra : `pip install "electricore-client[arrow]"`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from .transport import _BaseClient
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only, jamais exécuté
|
|
23
|
+
import polars as pl
|
|
24
|
+
|
|
25
|
+
_HINT_INSTALL = (
|
|
26
|
+
'Le client Arrow nécessite polars : installez l\'extra `arrow` (`pip install "electricore-client[arrow]"`).'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _polars():
|
|
31
|
+
"""Importe polars paresseusement, avec un message d'install clair s'il manque."""
|
|
32
|
+
try:
|
|
33
|
+
import polars as pl
|
|
34
|
+
except ModuleNotFoundError as exc: # pragma: no cover - testé via monkeypatch
|
|
35
|
+
raise ModuleNotFoundError(_HINT_INSTALL) from exc
|
|
36
|
+
return pl
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ElectricoreArrowClient(_BaseClient):
|
|
40
|
+
"""Client Arrow synchrone : endpoints structurés rendus en `pl.DataFrame`.
|
|
41
|
+
|
|
42
|
+
Hérite du substrat de transport partagé (URL, `X-API-Key`, timeout, 503 →
|
|
43
|
+
`IngestionEnCours`). polars n'est tiré qu'au premier appel de méthode.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def _get_arrow(self, path: str, params: dict) -> pl.DataFrame:
|
|
47
|
+
pl = _polars()
|
|
48
|
+
response = self._http.get(f"{self.url}{path}", params=params, headers=self._headers)
|
|
49
|
+
self._raise_for_status(response)
|
|
50
|
+
return pl.read_ipc_stream(io.BytesIO(response.content))
|
|
51
|
+
|
|
52
|
+
def flux(
|
|
53
|
+
self,
|
|
54
|
+
table_name: str,
|
|
55
|
+
*,
|
|
56
|
+
prm: str | None = None,
|
|
57
|
+
limit: int = 1_000_000,
|
|
58
|
+
) -> pl.DataFrame:
|
|
59
|
+
"""Contenu brut d'un flux Enedis (c15, r151, f15, etc.) en Polars.
|
|
60
|
+
|
|
61
|
+
Équivalent HTTP des `c15()`/`r151()`/`f15()` du `DuckDBQuery` — pour les
|
|
62
|
+
notebooks distants sans accès local à la base (ADR-0009).
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
table_name: nom de la table flux (`c15`, `r151`, `r15`, `f15_detail`, …).
|
|
66
|
+
prm: filtre optionnel sur la colonne `pdl`.
|
|
67
|
+
limit: nombre maximum de lignes (défaut 1 000 000, max serveur 10 000 000).
|
|
68
|
+
"""
|
|
69
|
+
params: dict[str, Any] = {"limit": limit}
|
|
70
|
+
if prm:
|
|
71
|
+
params["prm"] = prm
|
|
72
|
+
return self._get_arrow(f"/flux/{table_name}.arrow", params)
|
|
73
|
+
|
|
74
|
+
def releves(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
prm: str | None = None,
|
|
78
|
+
source: str | None = None,
|
|
79
|
+
debut: str | None = None,
|
|
80
|
+
fin: str | None = None,
|
|
81
|
+
limit: int = 1_000_000,
|
|
82
|
+
) -> pl.DataFrame:
|
|
83
|
+
"""Mart de relevés canonique `releves` (ADR-0029) en Polars.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
prm: filtre optionnel sur la colonne `pdl`.
|
|
87
|
+
source: filtre optionnel (`flux_R151` / `flux_R64` / `flux_C15`).
|
|
88
|
+
debut: borne basse incluse sur `date_releve` (ex. `"2025-01-01"`).
|
|
89
|
+
fin: borne haute incluse sur `date_releve` (ex. `"2025-12-31"`).
|
|
90
|
+
limit: nombre maximum de lignes (défaut 1 000 000, max serveur 10 000 000).
|
|
91
|
+
"""
|
|
92
|
+
params: dict[str, Any] = {"limit": limit}
|
|
93
|
+
if prm:
|
|
94
|
+
params["prm"] = prm
|
|
95
|
+
if source:
|
|
96
|
+
params["source"] = source
|
|
97
|
+
if debut:
|
|
98
|
+
params["debut"] = debut
|
|
99
|
+
if fin:
|
|
100
|
+
params["fin"] = fin
|
|
101
|
+
return self._get_arrow("/releves.arrow", params)
|
|
102
|
+
|
|
103
|
+
def facturation(self, mois: str | None = None) -> pl.DataFrame:
|
|
104
|
+
"""`lignes_facture_rapprochees` pour le mois donné.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
mois: "YYYY-MM-DD" — défaut : dernier mois disponible côté serveur.
|
|
108
|
+
"""
|
|
109
|
+
return self._get_arrow("/facturation/detail.arrow", {"mois": mois} if mois else {})
|
|
110
|
+
|
|
111
|
+
def accise(self, trimestre: str | None = None) -> pl.DataFrame:
|
|
112
|
+
"""Détail Accise TICFE pour le trimestre donné.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
trimestre: "YYYY-TX" (ex. "2025-T1") — défaut : tous les trimestres.
|
|
116
|
+
"""
|
|
117
|
+
return self._get_arrow("/taxes/accise/detail.arrow", {"trimestre": trimestre} if trimestre else {})
|
|
118
|
+
|
|
119
|
+
def cta(self, trimestre: str | None = None) -> pl.DataFrame:
|
|
120
|
+
"""Détail CTA mensuel pour le trimestre donné.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
trimestre: "YYYY-TX" (ex. "2025-T1") — défaut : tous les trimestres.
|
|
124
|
+
"""
|
|
125
|
+
return self._get_arrow("/taxes/cta/detail.arrow", {"trimestre": trimestre} if trimestre else {})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["ElectricoreArrowClient"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Client de la facturiste electricore (httpx + pydantic, sans polars).
|
|
2
|
+
|
|
3
|
+
`ElectricoreClient` assemble le substrat de transport (`_BaseClient`) et les
|
|
4
|
+
méthodes d'endpoint. Ce module reste **polars-free** : le client Arrow
|
|
5
|
+
historique vit dans le sous-module `arrow` (extra `[arrow]`), jamais importé
|
|
6
|
+
ici au top-level.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .models.chronologie import (
|
|
12
|
+
CONTRAT_VERSION_CHRONOLOGIE,
|
|
13
|
+
LigneEvenement,
|
|
14
|
+
LignePeriodeEnergie,
|
|
15
|
+
LigneReleve,
|
|
16
|
+
valider_ligne_chronologie,
|
|
17
|
+
)
|
|
18
|
+
from .models.meta_periodes import (
|
|
19
|
+
CONTRAT_VERSION_META_PERIODES,
|
|
20
|
+
PeriodeMeta,
|
|
21
|
+
)
|
|
22
|
+
from .models.turpe_variable import (
|
|
23
|
+
CONTRAT_VERSION_TURPE_VARIABLE,
|
|
24
|
+
LigneTurpeVariable,
|
|
25
|
+
ResultatTurpeVariable,
|
|
26
|
+
TurpeVariableRequest,
|
|
27
|
+
)
|
|
28
|
+
from .streaming import JsonlStream
|
|
29
|
+
from .transport import _BaseClient
|
|
30
|
+
|
|
31
|
+
# Type de ligne résolu par l'union discriminée de la chronologie.
|
|
32
|
+
LigneFrise = LigneEvenement | LigneReleve | LignePeriodeEnergie
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ElectricoreClient(_BaseClient):
|
|
36
|
+
"""Client synchrone vers une instance de l'API facturiste electricore.
|
|
37
|
+
|
|
38
|
+
Construit avec une `url` de base et une `api_key` (en-tête `X-API-Key`).
|
|
39
|
+
Les méthodes de lecture (méta-périodes, chronologie) streament du JSONL ;
|
|
40
|
+
`turpe_variable` est un POST RPC. Le client Arrow (DataFrames polars) est
|
|
41
|
+
fourni séparément via l'extra `[arrow]` (`electricore_client.arrow`).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def meta_periodes(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
mois: str | None = None,
|
|
48
|
+
rsc: list[str] | None = None,
|
|
49
|
+
page_size: int | None = None,
|
|
50
|
+
) -> JsonlStream[PeriodeMeta]:
|
|
51
|
+
"""Flux JSONL typé des méta-périodes mensuelles (contrat v3, ADR-0027/0038).
|
|
52
|
+
|
|
53
|
+
Le serveur **streame toutes les lignes** (pas de pagination) ; les
|
|
54
|
+
métadonnées (`contract_version`, `mois` résolu) sont dans les en-têtes
|
|
55
|
+
de réponse, lisibles via le flux retourné. La garde de version est
|
|
56
|
+
appliquée à l'ouverture (`__enter__`).
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
mois: mois cible `YYYY-MM-DD` (premier du mois) ; `None` → dernier
|
|
60
|
+
mois disponible côté serveur.
|
|
61
|
+
rsc: filtre optionnel sur une liste de `ref_situation_contractuelle`.
|
|
62
|
+
page_size: indication optionnelle de taille de lot serveur (hint).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Un `JsonlStream[PeriodeMeta]`, à consommer en context-manager :
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
with client.meta_periodes(mois="2026-05-01") as stream:
|
|
69
|
+
stream.contract_version
|
|
70
|
+
for periode in stream:
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
params: dict[str, object] = {}
|
|
75
|
+
if mois is not None:
|
|
76
|
+
params["mois"] = mois
|
|
77
|
+
if rsc:
|
|
78
|
+
params["rsc"] = rsc
|
|
79
|
+
if page_size is not None:
|
|
80
|
+
params["page_size"] = page_size
|
|
81
|
+
|
|
82
|
+
return JsonlStream(
|
|
83
|
+
client=self._http,
|
|
84
|
+
method="GET",
|
|
85
|
+
url=f"{self.url}/facturation/meta-periodes",
|
|
86
|
+
params=params or None,
|
|
87
|
+
headers=self._headers,
|
|
88
|
+
validateur=PeriodeMeta.model_validate,
|
|
89
|
+
version_attendue=CONTRAT_VERSION_META_PERIODES,
|
|
90
|
+
verifier_version=lambda attendue, servie: self._verifier_version(attendue=attendue, servie=servie),
|
|
91
|
+
raise_for_status=self._raise_for_status,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def chronologie(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
pdl: str | None = None,
|
|
98
|
+
rsc: str | None = None,
|
|
99
|
+
page_size: int | None = None,
|
|
100
|
+
) -> JsonlStream[LigneFrise]:
|
|
101
|
+
"""Flux JSONL de la frise d'un point (`pdl`) **ou** d'un contrat (`rsc`) — contrat v1.
|
|
102
|
+
|
|
103
|
+
Chaque ligne se résout en son sous-type via l'union discriminée
|
|
104
|
+
`LigneChronologie` (`LigneEvenement | LigneReleve | LignePeriodeEnergie`).
|
|
105
|
+
Faits + verdicts, **sans montant tarifaire** (différenciateur vs
|
|
106
|
+
méta-périodes). Pas de pagination ; `contract_version`/`grain` en en-têtes.
|
|
107
|
+
|
|
108
|
+
Le grain est validé **côté client** (`pdl` XOR `rsc`) — un `ValueError`
|
|
109
|
+
est levé *avant* toute requête (miroir du 422 serveur).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
pdl: grain point — toute l'histoire du PDL (RSC successives + charnières).
|
|
113
|
+
rsc: grain contrat — une tenure bornée entrée→sortie.
|
|
114
|
+
page_size: indication optionnelle de taille de lot serveur (hint).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: si ni `pdl` ni `rsc`, ou si les deux sont fournis (XOR).
|
|
118
|
+
"""
|
|
119
|
+
if (pdl is None) == (rsc is None):
|
|
120
|
+
raise ValueError("Fournir exactement un grain : `pdl` (point) XOR `rsc` (contrat).")
|
|
121
|
+
|
|
122
|
+
params: dict[str, object] = {}
|
|
123
|
+
if pdl is not None:
|
|
124
|
+
params["pdl"] = pdl
|
|
125
|
+
if rsc is not None:
|
|
126
|
+
params["rsc"] = rsc
|
|
127
|
+
if page_size is not None:
|
|
128
|
+
params["page_size"] = page_size
|
|
129
|
+
|
|
130
|
+
return JsonlStream(
|
|
131
|
+
client=self._http,
|
|
132
|
+
method="GET",
|
|
133
|
+
url=f"{self.url}/facturation/chronologie",
|
|
134
|
+
params=params or None,
|
|
135
|
+
headers=self._headers,
|
|
136
|
+
validateur=valider_ligne_chronologie,
|
|
137
|
+
version_attendue=CONTRAT_VERSION_CHRONOLOGIE,
|
|
138
|
+
verifier_version=lambda attendue, servie: self._verifier_version(attendue=attendue, servie=servie),
|
|
139
|
+
raise_for_status=self._raise_for_status,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def turpe_variable(
|
|
143
|
+
self,
|
|
144
|
+
lignes: list[LigneTurpeVariable | dict],
|
|
145
|
+
) -> list[ResultatTurpeVariable]:
|
|
146
|
+
"""Valorise un lot d'assiettes TURPE variable — **POST RPC** (pas un stream).
|
|
147
|
+
|
|
148
|
+
Petit calcul stateless, batch POST : un round-trip pour tout le lot. Chaque
|
|
149
|
+
résultat porte `turpe_variable_eur` **ou** un `error`, **apparié à l'entrée
|
|
150
|
+
par l'`id` opaque** ré-émis (jamais positionnel — un serveur libre de
|
|
151
|
+
réordonner reste correct). La garde de version s'applique (en-tête).
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
lignes: lot de `LigneTurpeVariable` (ou de dicts validés en
|
|
155
|
+
`LigneTurpeVariable`) — `{id, formule_tarifaire_acheminement, debut,
|
|
156
|
+
energie_*_kwh}` (7 cadrans nullables).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Liste de `ResultatTurpeVariable` (autant que d'entrées). Pour un
|
|
160
|
+
appariement explicite : `{r.id: r for r in resultats}`.
|
|
161
|
+
"""
|
|
162
|
+
requete = TurpeVariableRequest(
|
|
163
|
+
lignes=[
|
|
164
|
+
ligne if isinstance(ligne, LigneTurpeVariable) else LigneTurpeVariable.model_validate(ligne)
|
|
165
|
+
for ligne in lignes
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
response = self._http.post(
|
|
169
|
+
f"{self.url}/facturation/turpe-variable",
|
|
170
|
+
content=requete.model_dump_json(),
|
|
171
|
+
headers={**self._headers, "Content-Type": "application/json"},
|
|
172
|
+
)
|
|
173
|
+
self._raise_for_status(response)
|
|
174
|
+
|
|
175
|
+
version_servie = response.headers.get("X-Contract-Version")
|
|
176
|
+
if version_servie is not None:
|
|
177
|
+
self._verifier_version(attendue=CONTRAT_VERSION_TURPE_VARIABLE, servie=int(version_servie))
|
|
178
|
+
|
|
179
|
+
corps = response.json()
|
|
180
|
+
return [ResultatTurpeVariable.model_validate(r) for r in corps["results"]]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exceptions du client electricore.
|
|
2
|
+
|
|
3
|
+
Toutes dérivent d'`ElectricoreClientError` pour qu'un appelant puisse attraper
|
|
4
|
+
*toute* erreur du client en une seule clause. `IngestionEnCours` est le cas
|
|
5
|
+
métier distingué : la base DuckDB est verrouillée par un cycle d'ingestion
|
|
6
|
+
(l'API répond **503**), et l'appelant peut simplement réessayer plus tard.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ElectricoreClientError(Exception):
|
|
13
|
+
"""Racine de toutes les erreurs levées par le client."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IngestionEnCours(ElectricoreClientError):
|
|
17
|
+
"""L'API a répondu 503 : la base est verrouillée par un cycle d'ingestion.
|
|
18
|
+
|
|
19
|
+
Transitoire — l'API se rétablit d'elle-même après le checkpoint. L'appelant
|
|
20
|
+
peut réessayer après un délai court (cf. `main.verrou_duckdb_en_503`, #171).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContractVersionError(ElectricoreClientError):
|
|
25
|
+
"""Le contrat servi par le serveur est plus **ancien** que celui attendu.
|
|
26
|
+
|
|
27
|
+
Garde asymétrique : un serveur *en avance* (version > attendue) ne fait que
|
|
28
|
+
`warn` (le client tolère l'additif via `extra="ignore"`) ; un serveur *en
|
|
29
|
+
retard* (version < attendue) lève cette erreur — le client réclamerait des
|
|
30
|
+
champs absents.
|
|
31
|
+
"""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Métadonnées portées par les en-têtes de réponse.
|
|
2
|
+
|
|
3
|
+
Les endpoints de lecture streament leurs lignes en JSONL : ils n'ont plus
|
|
4
|
+
d'enveloppe pour loger la version de contrat et le contexte résolu (mois,
|
|
5
|
+
grain). Ces métadonnées remontent donc dans des en-têtes HTTP, parsés ici en
|
|
6
|
+
un petit modèle typé.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
# En-têtes de métadonnées (conventionnés serveur + client).
|
|
16
|
+
HEADER_CONTRACT_VERSION = "X-Contract-Version"
|
|
17
|
+
HEADER_MOIS = "X-Mois"
|
|
18
|
+
HEADER_GRAIN = "X-Grain"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnTetesMeta(BaseModel):
|
|
22
|
+
"""Métadonnées d'un flux JSONL, extraites des en-têtes de réponse.
|
|
23
|
+
|
|
24
|
+
`contract_version` est toujours présent ; `mois` (méta-périodes) et `grain`
|
|
25
|
+
(chronologie) le sont selon l'endpoint.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
contract_version: int
|
|
31
|
+
mois: str | None = None
|
|
32
|
+
grain: str | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_headers(cls, headers: Mapping[str, str]) -> EnTetesMeta:
|
|
36
|
+
"""Parse les en-têtes de réponse (insensibles à la casse via httpx)."""
|
|
37
|
+
version = headers.get(HEADER_CONTRACT_VERSION)
|
|
38
|
+
if version is None:
|
|
39
|
+
raise ValueError(f"En-tête manquant : {HEADER_CONTRACT_VERSION}")
|
|
40
|
+
return cls(
|
|
41
|
+
contract_version=int(version),
|
|
42
|
+
mois=headers.get(HEADER_MOIS),
|
|
43
|
+
grain=headers.get(HEADER_GRAIN),
|
|
44
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Modèles de contrat des endpoints facturiste (source unique, ADR-0043).
|
|
2
|
+
|
|
3
|
+
Ces modèles pydantic décrivent les lignes émises par l'API en JSONL. Ils sont
|
|
4
|
+
**single-sourcés** : le moteur (routers FastAPI) les importe d'ici plutôt que
|
|
5
|
+
de redéfinir des modèles inline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .chronologie import (
|
|
11
|
+
CONTRAT_VERSION_CHRONOLOGIE,
|
|
12
|
+
LigneChronologie,
|
|
13
|
+
LigneEvenement,
|
|
14
|
+
LignePeriodeEnergie,
|
|
15
|
+
LigneReleve,
|
|
16
|
+
valider_ligne_chronologie,
|
|
17
|
+
)
|
|
18
|
+
from .meta_periodes import CONTRAT_VERSION_META_PERIODES, ObjetReleve, PeriodeMeta
|
|
19
|
+
from .turpe_variable import (
|
|
20
|
+
CONTRAT_VERSION_TURPE_VARIABLE,
|
|
21
|
+
LigneTurpeVariable,
|
|
22
|
+
ResultatTurpeVariable,
|
|
23
|
+
TurpeVariableRequest,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PeriodeMeta",
|
|
28
|
+
"ObjetReleve",
|
|
29
|
+
"CONTRAT_VERSION_META_PERIODES",
|
|
30
|
+
"LigneChronologie",
|
|
31
|
+
"LigneEvenement",
|
|
32
|
+
"LigneReleve",
|
|
33
|
+
"LignePeriodeEnergie",
|
|
34
|
+
"CONTRAT_VERSION_CHRONOLOGIE",
|
|
35
|
+
"valider_ligne_chronologie",
|
|
36
|
+
"LigneTurpeVariable",
|
|
37
|
+
"TurpeVariableRequest",
|
|
38
|
+
"ResultatTurpeVariable",
|
|
39
|
+
"CONTRAT_VERSION_TURPE_VARIABLE",
|
|
40
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Modèles de contrat de la chronologie facturiste (contrat v1, ADR-0039/0041).
|
|
2
|
+
|
|
3
|
+
Miroir de `chronologie_service` : la frise d'un point/contrat est une suite de
|
|
4
|
+
lignes hétérogènes — **faits** (événements C15, relevés) tissés avec les
|
|
5
|
+
**verdicts** des périodes d'énergie. Une ligne est donc une **union discriminée**
|
|
6
|
+
(pydantic v2) sur `type_ligne` : `evenement | releve | periode_energie`.
|
|
7
|
+
|
|
8
|
+
Tout est optionnel (sauf le discriminant + `date`) : registres et énergies ne
|
|
9
|
+
sont émis que non-nuls (jamais de cadran synthétisé). Typage (ADR-0034) :
|
|
10
|
+
`index_*_kwh` entiers, `energie_*_kwh` flottants. **Pas de montant tarifaire**
|
|
11
|
+
(différenciateur vs méta-périodes).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Annotated, Literal
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
19
|
+
|
|
20
|
+
# Version du contrat (cf. chronologie_service.CONTRAT_VERSION). Source unique.
|
|
21
|
+
CONTRAT_VERSION_CHRONOLOGIE = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _LigneBase(BaseModel):
|
|
25
|
+
"""Champs partagés par toute ligne de frise (additive-tolérante)."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="ignore")
|
|
28
|
+
|
|
29
|
+
date: str
|
|
30
|
+
pdl: str | None = None
|
|
31
|
+
ref_situation_contractuelle: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LigneEvenement(_LigneBase):
|
|
35
|
+
"""Un fait événementiel : événement C15 (y compris hors-comptage) ou borne FACTURATION.
|
|
36
|
+
|
|
37
|
+
Porte la situation au moment du fait (puissance, FTA, niveau d'ouverture) et
|
|
38
|
+
les annotations de rupture d'abonnement.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
type_ligne: Literal["evenement"]
|
|
42
|
+
source: str | None = None
|
|
43
|
+
type_fait: str | None = None
|
|
44
|
+
evenement_declencheur: str | None = None
|
|
45
|
+
puissance_souscrite_kva: float | None = None
|
|
46
|
+
formule_tarifaire_acheminement: str | None = None
|
|
47
|
+
niveau_ouverture_services: str | None = None
|
|
48
|
+
impacte_abonnement: bool | None = None
|
|
49
|
+
resume_modification: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LigneReleve(_LigneBase):
|
|
53
|
+
"""Un fait de relevé : index utilisé, avec origine (périodique/événementiel) et nature.
|
|
54
|
+
|
|
55
|
+
Seuls les registres réels (non nuls) ressortent. `evenement_declencheur` n'est
|
|
56
|
+
présent que pour un relevé d'origine événementielle (C15).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
type_ligne: Literal["releve"]
|
|
60
|
+
source: str | None = None
|
|
61
|
+
releve_id: str | None = None
|
|
62
|
+
nature_index: str | None = None
|
|
63
|
+
origine_releve: str | None = None
|
|
64
|
+
ordre_index: int | None = None
|
|
65
|
+
evenement_declencheur: str | None = None
|
|
66
|
+
|
|
67
|
+
# Registres réels (index_*_kwh) — entiers (ADR-0034), non-nuls uniquement.
|
|
68
|
+
index_base_kwh: int | None = None
|
|
69
|
+
index_hp_kwh: int | None = None
|
|
70
|
+
index_hc_kwh: int | None = None
|
|
71
|
+
index_hph_kwh: int | None = None
|
|
72
|
+
index_hch_kwh: int | None = None
|
|
73
|
+
index_hpb_kwh: int | None = None
|
|
74
|
+
index_hcb_kwh: int | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LignePeriodeEnergie(_LigneBase):
|
|
78
|
+
"""Une période d'énergie dérivée : bornes + **verdicts** (qualité/communication) + énergie
|
|
79
|
+
physique (kWh). **Aucun** montant tarifaire (ADR-0027)."""
|
|
80
|
+
|
|
81
|
+
type_ligne: Literal["periode_energie"]
|
|
82
|
+
debut: str | None = None
|
|
83
|
+
fin: str | None = None
|
|
84
|
+
nb_jours: int | None = None
|
|
85
|
+
qualite: str | None = None
|
|
86
|
+
statut_communication: str | None = None
|
|
87
|
+
|
|
88
|
+
# Énergies (energie_*_kwh) — flottants (ADR-0034), non-nulles uniquement.
|
|
89
|
+
energie_base_kwh: float | None = None
|
|
90
|
+
energie_hp_kwh: float | None = None
|
|
91
|
+
energie_hc_kwh: float | None = None
|
|
92
|
+
energie_hph_kwh: float | None = None
|
|
93
|
+
energie_hch_kwh: float | None = None
|
|
94
|
+
energie_hpb_kwh: float | None = None
|
|
95
|
+
energie_hcb_kwh: float | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Union discriminée sur `type_ligne` : pydantic résout chaque ligne au bon sous-type.
|
|
99
|
+
LigneChronologie = Annotated[
|
|
100
|
+
LigneEvenement | LigneReleve | LignePeriodeEnergie,
|
|
101
|
+
Field(discriminator="type_ligne"),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Adaptateur réutilisable pour valider une ligne brute (dict) → sous-type concret.
|
|
105
|
+
_ADAPTER: TypeAdapter = TypeAdapter(LigneChronologie)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def valider_ligne_chronologie(ligne: dict) -> LigneEvenement | LigneReleve | LignePeriodeEnergie:
|
|
109
|
+
"""Valide une ligne brute en son sous-type concret via l'union discriminée."""
|
|
110
|
+
return _ADAPTER.validate_python(ligne)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Modèles de contrat des méta-périodes mensuelles (contrat v3, ADR-0027/0038).
|
|
2
|
+
|
|
3
|
+
Miroir de `meta_periodes_service.COLONNES_CONTRAT` + `source_hash` + le tableau
|
|
4
|
+
imbriqué `releves_utilises` (ADR-0038). Conventions de typage (ADR-0034) : les
|
|
5
|
+
index de compteur (`index_*_kwh`) sont des **entiers** (kWh entier au boundary
|
|
6
|
+
dbt) ; les énergies (`energie_*_kwh`) et montants (`*_eur`) sont des **flottants**.
|
|
7
|
+
|
|
8
|
+
`model_config = extra="ignore"` : le contrat est additif-tolérant — un serveur
|
|
9
|
+
plus récent qui ajoute une colonne ne casse pas un client plus ancien.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
# Version du contrat (cf. meta_periodes_service.CONTRAT_VERSION). Source unique.
|
|
17
|
+
CONTRAT_VERSION_META_PERIODES = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ObjetReleve(BaseModel):
|
|
21
|
+
"""Un relevé d'index utilisé pour borner une méta-période (ADR-0038).
|
|
22
|
+
|
|
23
|
+
Seuls les registres réellement affichés par le compteur ressortent (les
|
|
24
|
+
autres sont absents, pas null). `evenement` n'est présent que pour un relevé
|
|
25
|
+
d'origine événementielle (C15).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
releve_id: str
|
|
31
|
+
date_releve: str
|
|
32
|
+
nature_index: str | None = None
|
|
33
|
+
origine_releve: str | None = None
|
|
34
|
+
evenement: str | None = None
|
|
35
|
+
|
|
36
|
+
# Registres réels (index_*_kwh) — entiers (ADR-0034), seuls les non-nuls émis.
|
|
37
|
+
index_base_kwh: int | None = None
|
|
38
|
+
index_hp_kwh: int | None = None
|
|
39
|
+
index_hc_kwh: int | None = None
|
|
40
|
+
index_hph_kwh: int | None = None
|
|
41
|
+
index_hch_kwh: int | None = None
|
|
42
|
+
index_hpb_kwh: int | None = None
|
|
43
|
+
index_hcb_kwh: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PeriodeMeta(BaseModel):
|
|
47
|
+
"""Méta-période mensuelle d'un contrat — agrégat valorisé (contrat v3).
|
|
48
|
+
|
|
49
|
+
Quantités physiques + montants réseau (pas de prix fournisseur). Porte la
|
|
50
|
+
trace d'index imbriquée `releves_utilises` (ADR-0038) et `source_hash`
|
|
51
|
+
(intégrité de contenu, ADR-0027/0038).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="ignore")
|
|
55
|
+
|
|
56
|
+
ref_situation_contractuelle: str
|
|
57
|
+
pdl: str
|
|
58
|
+
mois_annee: str
|
|
59
|
+
debut: str
|
|
60
|
+
fin: str
|
|
61
|
+
nb_jours: int
|
|
62
|
+
puissance_moyenne_kva: float | None = None
|
|
63
|
+
formule_tarifaire_acheminement: str | None = None
|
|
64
|
+
|
|
65
|
+
# Énergies (kWh) — flottants (ADR-0034).
|
|
66
|
+
energie_base_kwh: float | None = None
|
|
67
|
+
energie_hp_kwh: float | None = None
|
|
68
|
+
energie_hc_kwh: float | None = None
|
|
69
|
+
|
|
70
|
+
# Montants réseau (€) — flottants.
|
|
71
|
+
turpe_fixe_eur: float | None = None
|
|
72
|
+
turpe_variable_eur: float | None = None
|
|
73
|
+
cta_eur: float | None = None
|
|
74
|
+
|
|
75
|
+
# Taux accise (€/MWh) — flottant (taux, pas montant : assiette possédée par l'ERP).
|
|
76
|
+
taux_accise_eur_mwh: float | None = None
|
|
77
|
+
|
|
78
|
+
has_changement: bool | None = None
|
|
79
|
+
|
|
80
|
+
# Verdicts méta jumeaux (qualité ADR-0033 / communication ADR-0036).
|
|
81
|
+
qualite: str | None = None
|
|
82
|
+
statut_communication: str | None = None
|
|
83
|
+
|
|
84
|
+
# Trace d'index légale (ADR-0038) — imbriquée, requise (peut être vide).
|
|
85
|
+
releves_utilises: list[ObjetReleve] = []
|
|
86
|
+
|
|
87
|
+
# Intégrité de contenu (ADR-0027/0038) — requise.
|
|
88
|
+
source_hash: str
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Modèles de contrat du calculateur TURPE variable (POST RPC, ADR-0030, #247/#409).
|
|
2
|
+
|
|
3
|
+
Calculateur **sans état** : l'appelant prête l'assiette (énergies par cadran +
|
|
4
|
+
FTA + `debut`), electricore renvoie le **montant** € — ou un motif d'erreur, par
|
|
5
|
+
`id`, jamais de silent-drop. L'`id` est **opaque** : ré-émis tel quel, jamais
|
|
6
|
+
interprété. Les 7 cadrans sont nullables (null → 0).
|
|
7
|
+
|
|
8
|
+
Single-sourcés ici (ADR-0043) : le router FastAPI les importe, ne les redéfinit pas.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
# Version du contrat (cf. turpe_variable_service.CONTRAT_VERSION). Source unique.
|
|
18
|
+
CONTRAT_VERSION_TURPE_VARIABLE = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LigneTurpeVariable(BaseModel):
|
|
22
|
+
"""Une ligne d'assiette à valoriser. Les 7 cadrans sont nullables (null → 0)."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="ignore")
|
|
25
|
+
|
|
26
|
+
id: str = Field(..., description="Identifiant opaque ré-émis tel quel (electricore ne l'interprète jamais)")
|
|
27
|
+
formule_tarifaire_acheminement: str = Field(..., description="FTA, ex. BTINFCUST")
|
|
28
|
+
debut: datetime = Field(..., description="Début de période (tz Europe/Paris) — sélection temporelle de la règle")
|
|
29
|
+
energie_base_kwh: float | None = None
|
|
30
|
+
energie_hp_kwh: float | None = None
|
|
31
|
+
energie_hc_kwh: float | None = None
|
|
32
|
+
energie_hph_kwh: float | None = None
|
|
33
|
+
energie_hpb_kwh: float | None = None
|
|
34
|
+
energie_hch_kwh: float | None = None
|
|
35
|
+
energie_hcb_kwh: float | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TurpeVariableRequest(BaseModel):
|
|
39
|
+
"""Lot de lignes à valoriser en une passe."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="ignore")
|
|
42
|
+
|
|
43
|
+
lignes: list[LigneTurpeVariable] = Field(..., description="Lot de lignes (le cas mono-période est n=1)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ResultatTurpeVariable(BaseModel):
|
|
47
|
+
"""Résultat pour un `id` : **soit** `turpe_variable_eur`, **soit** `error` (xor).
|
|
48
|
+
|
|
49
|
+
Jamais de silent-drop (ADR-0030) : chaque `id` envoyé revient. `error` porte le
|
|
50
|
+
motif (FTA inconnue, aucune règle pour la date) ; `turpe_variable_eur` le montant.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="ignore")
|
|
54
|
+
|
|
55
|
+
id: str
|
|
56
|
+
turpe_variable_eur: float | None = None
|
|
57
|
+
error: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Flux JSONL typé : un context-manager qui valide chaque ligne en un modèle.
|
|
2
|
+
|
|
3
|
+
Les endpoints de lecture (méta-périodes, chronologie) streament leur résultat
|
|
4
|
+
en JSONL — une ligne = un objet JSON, validé à la volée par un modèle pydantic.
|
|
5
|
+
Les métadonnées (version de contrat, mois/grain) arrivent en en-têtes ; la garde
|
|
6
|
+
de version est appliquée à l'ouverture (`__enter__`). Le flux est lazy (rien
|
|
7
|
+
n'est matérialisé tant qu'on n'itère pas) et libère la connexion à la sortie du
|
|
8
|
+
`with`, **même mi-consommé**.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
from typing import Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .headers import EnTetesMeta
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
# Type-construit une ligne (dict JSON décodé) → modèle typé. En pratique
|
|
24
|
+
# `Model.model_validate`, mais on garde la signature ouverte (unions discriminées).
|
|
25
|
+
Validateur = Callable[[dict], T]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JsonlStream(Generic[T]):
|
|
29
|
+
"""Itérateur paresseux sur un flux JSONL, validé ligne à ligne.
|
|
30
|
+
|
|
31
|
+
Construit autour d'un `httpx.Client.stream(...)` (context-manager de requête).
|
|
32
|
+
`__enter__` ouvre la réponse, parse les en-têtes de métadonnées et applique la
|
|
33
|
+
garde de version ; l'itération décode et valide chaque ligne non vide. À la
|
|
34
|
+
sortie du `with`, la réponse est fermée (connexion rendue au pool) qu'elle ait
|
|
35
|
+
été entièrement consommée ou non.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
client: httpx.Client,
|
|
42
|
+
method: str,
|
|
43
|
+
url: str,
|
|
44
|
+
params: dict | None,
|
|
45
|
+
headers: dict[str, str],
|
|
46
|
+
validateur: Validateur[T],
|
|
47
|
+
version_attendue: int,
|
|
48
|
+
verifier_version: Callable[[int, int], None],
|
|
49
|
+
raise_for_status: Callable[[httpx.Response], None],
|
|
50
|
+
) -> None:
|
|
51
|
+
self._request_cm = client.stream(method, url, params=params, headers=headers)
|
|
52
|
+
self._response: httpx.Response | None = None
|
|
53
|
+
self._validateur = validateur
|
|
54
|
+
self._version_attendue = version_attendue
|
|
55
|
+
self._verifier_version = verifier_version
|
|
56
|
+
self._raise_for_status = raise_for_status
|
|
57
|
+
self._meta: EnTetesMeta | None = None
|
|
58
|
+
self._closed = False
|
|
59
|
+
|
|
60
|
+
# -- context manager ------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> JsonlStream[T]:
|
|
63
|
+
response = self._request_cm.__enter__()
|
|
64
|
+
self._response = response
|
|
65
|
+
self._raise_for_status(response)
|
|
66
|
+
self._meta = EnTetesMeta.from_headers(response.headers)
|
|
67
|
+
# Garde de version appliquée à l'ouverture (avant toute consommation).
|
|
68
|
+
self._verifier_version(self._version_attendue, self._meta.contract_version)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *exc: object) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Libère le flux (connexion rendue au pool), idempotent et mi-consommé-safe."""
|
|
76
|
+
if self._closed:
|
|
77
|
+
return
|
|
78
|
+
self._closed = True
|
|
79
|
+
self._request_cm.__exit__(None, None, None)
|
|
80
|
+
|
|
81
|
+
# -- métadonnées (en-têtes) ----------------------------------------------
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def meta(self) -> EnTetesMeta:
|
|
85
|
+
if self._meta is None:
|
|
86
|
+
raise RuntimeError("Flux non ouvert : utiliser `with client.<endpoint>(...) as stream:`")
|
|
87
|
+
return self._meta
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def contract_version(self) -> int:
|
|
91
|
+
return self.meta.contract_version
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def mois(self) -> str | None:
|
|
95
|
+
return self.meta.mois
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def grain(self) -> str | None:
|
|
99
|
+
return self.meta.grain
|
|
100
|
+
|
|
101
|
+
# -- itération ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def __iter__(self) -> Iterator[T]:
|
|
104
|
+
if self._response is None:
|
|
105
|
+
raise RuntimeError("Flux non ouvert : utiliser `with client.<endpoint>(...) as stream:`")
|
|
106
|
+
for ligne in self._response.iter_lines():
|
|
107
|
+
if not ligne.strip():
|
|
108
|
+
continue
|
|
109
|
+
yield self._validateur(json.loads(ligne))
|
|
110
|
+
|
|
111
|
+
def collect(self) -> list[T]:
|
|
112
|
+
"""Matérialise tout le flux en liste (convenance)."""
|
|
113
|
+
return list(self)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Substrat de transport partagé par toutes les méthodes du client.
|
|
2
|
+
|
|
3
|
+
`_BaseClient` factorise tout ce qui est commun aux endpoints : URL de base,
|
|
4
|
+
en-tête `X-API-Key`, construction du `httpx.Client` (timeout), conversion
|
|
5
|
+
d'erreur HTTP (503 → `IngestionEnCours`) et la garde de version de contrat.
|
|
6
|
+
Les méthodes d'endpoint (méta-périodes, chronologie, turpe variable, Arrow)
|
|
7
|
+
sont montées par-dessus dans `client.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .exceptions import ContractVersionError, IngestionEnCours
|
|
17
|
+
|
|
18
|
+
# Timeout généreux en lecture : les flux JSONL peuvent durer (gros parc).
|
|
19
|
+
DEFAULT_TIMEOUT = httpx.Timeout(30.0, read=300.0)
|
|
20
|
+
|
|
21
|
+
# Code HTTP « ingestion en cours » (base DuckDB verrouillée par un writer, #171).
|
|
22
|
+
STATUS_INGESTION_EN_COURS = 503
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BaseClient:
|
|
26
|
+
"""Transport partagé : URL de base, auth, timeout, gestion d'erreur, garde de version."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
url: str,
|
|
31
|
+
api_key: str,
|
|
32
|
+
*,
|
|
33
|
+
http_client: httpx.Client | None = None,
|
|
34
|
+
timeout: httpx.Timeout | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.url = url.rstrip("/")
|
|
37
|
+
self.api_key = api_key
|
|
38
|
+
self._http = http_client or httpx.Client(timeout=timeout or DEFAULT_TIMEOUT)
|
|
39
|
+
|
|
40
|
+
# -- cycle de vie ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
"""Ferme le client HTTP sous-jacent."""
|
|
44
|
+
self._http.close()
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> _BaseClient:
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc: object) -> None:
|
|
50
|
+
self.close()
|
|
51
|
+
|
|
52
|
+
# -- helpers transport ----------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def _headers(self) -> dict[str, str]:
|
|
56
|
+
return {"X-API-Key": self.api_key}
|
|
57
|
+
|
|
58
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
59
|
+
"""Convertit les erreurs HTTP en exceptions du client.
|
|
60
|
+
|
|
61
|
+
503 (base verrouillée par l'ingestion) → `IngestionEnCours` ; tout autre
|
|
62
|
+
statut d'erreur → `httpx.HTTPStatusError` via `raise_for_status`.
|
|
63
|
+
"""
|
|
64
|
+
if response.status_code == STATUS_INGESTION_EN_COURS:
|
|
65
|
+
raise IngestionEnCours(
|
|
66
|
+
"L'API electricore est momentanément indisponible : un cycle d'ingestion "
|
|
67
|
+
"verrouille la base. Réessayer après le checkpoint."
|
|
68
|
+
)
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _verifier_version(*, attendue: int, servie: int) -> None:
|
|
73
|
+
"""Garde asymétrique sur la version de contrat (en-tête `X-Contract-Version`).
|
|
74
|
+
|
|
75
|
+
- serveur **en avance** (servie > attendue) → `warn` : le client tolère
|
|
76
|
+
l'additif (`model_config = extra="ignore"`), pas besoin d'échouer ;
|
|
77
|
+
- serveur **en retard** (servie < attendue) → `ContractVersionError` : le
|
|
78
|
+
client réclamerait des champs que le serveur n'émet plus.
|
|
79
|
+
"""
|
|
80
|
+
if servie > attendue:
|
|
81
|
+
warnings.warn(
|
|
82
|
+
f"Le serveur sert le contrat v{servie}, le client attend v{attendue} : "
|
|
83
|
+
f"des champs additifs peuvent être ignorés. Mettez le client à jour.",
|
|
84
|
+
stacklevel=3,
|
|
85
|
+
)
|
|
86
|
+
elif servie < attendue:
|
|
87
|
+
raise ContractVersionError(
|
|
88
|
+
f"Le serveur sert le contrat v{servie}, le client attend v{attendue} : "
|
|
89
|
+
f"trop ancien, des champs attendus sont absents. Mettez le serveur à jour."
|
|
90
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: electricore-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client Python léger (httpx + pydantic) vers l'API facturiste electricore — sans polars/duckdb/fastapi
|
|
5
|
+
Project-URL: Homepage, https://github.com/Energie-De-Nantes/electricore
|
|
6
|
+
Project-URL: Repository, https://github.com/Energie-De-Nantes/electricore
|
|
7
|
+
Project-URL: Issues, https://github.com/Energie-De-Nantes/electricore/issues
|
|
8
|
+
Author: Virgile
|
|
9
|
+
License: AGPL-3.0
|
|
10
|
+
Keywords: api-client,electricore,enedis,energy,facturation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: pydantic>=2.0.0
|
|
24
|
+
Provides-Extra: arrow
|
|
25
|
+
Requires-Dist: polars<2.0.0,>=1.0.0; extra == 'arrow'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# electricore-client
|
|
29
|
+
|
|
30
|
+
Client Python **léger** vers l'API facturiste [electricore](https://github.com/Energie-De-Nantes/electricore).
|
|
31
|
+
|
|
32
|
+
Dépendances de base : **httpx + pydantic uniquement** — pas de polars, duckdb ni
|
|
33
|
+
fastapi. Pensé pour être consommé par `souscriptions_odoo` (Odoo 19) et tout
|
|
34
|
+
intégrateur qui n'a pas besoin de tirer le moteur entier.
|
|
35
|
+
|
|
36
|
+
Lecture seule sur electricore (« Odoo tire d'electricore », ADR-0027/0012).
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install electricore-client # base : httpx + pydantic
|
|
42
|
+
pip install "electricore-client[arrow]" # + client Arrow (DataFrames polars)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from electricore_client import ElectricoreClient
|
|
49
|
+
|
|
50
|
+
client = ElectricoreClient(url="https://electricore.example", api_key="…")
|
|
51
|
+
|
|
52
|
+
# Méta-périodes mensuelles (flux JSONL typé, sans pagination)
|
|
53
|
+
with client.meta_periodes(mois="2026-05-01") as stream:
|
|
54
|
+
print(stream.contract_version) # version de contrat (en-tête)
|
|
55
|
+
for periode in stream: # itère des PeriodeMeta typés
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
# Chronologie d'un point ou d'un contrat (union discriminée)
|
|
59
|
+
with client.chronologie(pdl="12345678901234") as stream:
|
|
60
|
+
lignes = stream.collect()
|
|
61
|
+
|
|
62
|
+
# Calculateur TURPE variable (POST RPC, pas un stream)
|
|
63
|
+
resultats = client.turpe_variable([...]) # résultats indexés par id opaque
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Le client Arrow historique (`flux/releves/facturation/accise/cta` → `pl.DataFrame`)
|
|
67
|
+
vit dans `electricore_client.arrow`, derrière l'extra `[arrow]`.
|
|
68
|
+
|
|
69
|
+
## Conception
|
|
70
|
+
|
|
71
|
+
Voir [ADR-0043](https://github.com/Energie-De-Nantes/electricore/blob/main/docs/adr/0043-electricore-client-paquet-separe.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
electricore_client/__init__.py,sha256=RtqPn5wVcT94EvlxkzHWggUllsHKMW7g1B4vb9s0QvY,1391
|
|
2
|
+
electricore_client/arrow.py,sha256=IrUecusae-MgJ6lk-Ar0JO_CCz6rZggfyiH05ZHOiug,4841
|
|
3
|
+
electricore_client/client.py,sha256=4MbDPKkAVOtFb-2zdhonxnoPLLM2ofwaQSbZLFCO7ZE,7082
|
|
4
|
+
electricore_client/exceptions.py,sha256=qEgChZKqsbrfKewR5ZWOPQAHzccQhrEfqnHK_NSIT1g,1207
|
|
5
|
+
electricore_client/headers.py,sha256=WphP2TLqh1UMxCWL0bbihvuvXUsOh_6xILiQgEFSbc8,1461
|
|
6
|
+
electricore_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
electricore_client/streaming.py,sha256=Lu9HLphVLFM9Qez7ixtY5Ej1xhVv5RRjjWvte20UIAk,4098
|
|
8
|
+
electricore_client/transport.py,sha256=Uy3bUvLHs6ePKNefztJD3AuXBHysmq2JaTpBRd58syw,3425
|
|
9
|
+
electricore_client/models/__init__.py,sha256=RpQWIZZoGkqamq8kG0BibTjt7P-_5HkpIlVuIQESulY,1089
|
|
10
|
+
electricore_client/models/chronologie.py,sha256=yoiBzZTYFRWVYTcHFPSfhvoK8YfRsNJqNiouSz58ts8,4076
|
|
11
|
+
electricore_client/models/meta_periodes.py,sha256=KfFGaTWfDcRd060-Xt7iJh976Kctt02app8j8UidYBs,3083
|
|
12
|
+
electricore_client/models/turpe_variable.py,sha256=o9jCICtmyIwzI4ntqnzR7lXIAnC0MDT09cwumn2hnWk,2201
|
|
13
|
+
electricore_client-0.1.0.dist-info/METADATA,sha256=Z33_XxaHOj5nIW__LHIbJFCB-3hVrpOi2hHGYIW6Xyc,2811
|
|
14
|
+
electricore_client-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
electricore_client-0.1.0.dist-info/RECORD,,
|