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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any