Rôles des dossiers — loaders, pipelines, builds, writers, integrations, api
Contexte
La codebase a accrété un vocabulaire (core/loaders/, core/pipelines/, core/writers/, core/orchestrations/, integrations/odoo/, api/routers/, api/services/) sans définition stricte des rôles. Plusieurs dérives observables :
pipeline_ctaprend deux sources hétérogènes en entrée (df_facturationEnedis +df_pdlOdoo) et fait une jointure — c'est une orchestration déguisée en pipeline.integrations/odoo/taxes.py(depuis éclaté par la refonte) mélangeait dans un même fichier les sources Odoo (accise_par_contrat,cta_par_contrat) et les assembleurs de livrables (rapport_accise,rapport_cta,feuilles_rapport_*). Une partie de cette logique est ERP-agnostique et devrait vivre encore/selon ADR-0016.pipeline_facturationetpipeline_acciseappellent.collect()en interne, contre la pratique courante (Polars, dbplyr, Ibis) qui place la matérialisation au boundary.core/orchestrations/contexte_mensuel.py(depuis renommécore/builds/contexte_mensuel.py) était nommé « orchestration » alors qu'il assemble un livrable (ContexteMensueldataclass). Dans la convention industrielle (Airflow, Dagster jobs, Prefect, dlt), « orchestration » désigne le scheduling d'un DAG, pas l'assemblage d'un livrable.
Sans cadre explicite, chaque refactor perpétue ou aggrave l'ambiguïté. Le moment de figer la convention est avant d'attaquer la refonte Accise/CTA (issue à venir), qui crée de nouveaux modules et touche aux cinq rôles à la fois.
Décision
Cinq rôles dans core/, plus integrations/<erp>/ côté ERP et api/ côté transport. Vocabulaire et règles d'import figés ci-dessous.
Vocabulaire et responsabilités
| Dossier | Rôle | Entrée | Sortie | I/O | Peut importer |
|---|---|---|---|---|---|
core/models/ |
Schémas Pandera (contrats de forme) | — | — | non | (rien) |
core/loaders/ |
Sources internes (DuckDB, fichiers, configs) | identifiants (chemins, filtres) | LazyFrame[Schema core] ou query builders |
oui (DuckDB, fichiers) | core/models/ |
core/pipelines/ |
Transformations pures (suite d'opérations) | N frames Pandera-typés | 1 frame Pandera-typé | non | core/models/ |
core/builds/ |
Producteurs de livrables (bundles dataclass, side-effects) | frames fournis par caller OU appels à core/loaders/ |
dataclass (RapportTaxe, ContexteMensuel) ou side-effect via writer |
possible (via loaders) | core/models/, core/pipelines/, core/loaders/, core/writers/ |
core/writers/ |
Sinks internes (DuckDB, fichiers) | DataFrame |
side-effect | oui | core/models/ |
integrations/<erp>/ |
Sources et sinks ERP | requêtes ERP | LazyFrame[Schema core] (lecture) / side-effect (écriture) |
oui (ERP) | core/models/, core/loaders/ (rare) |
api/routers/ |
Transport HTTP (FastAPI, query params, sérialisation) | requête HTTP | réponse HTTP | non (délègue) | tout core/, integrations/<erp>/, api/services/ |
api/services/ |
Wire-up non-trivial ou stateful entre sources et builds | requête métier | DataFrame, bundle, ou statut | oui (compose sources + builds) | tout core/, integrations/<erp>/ |
Règles d'import (interdictions)
core/pipelines/n'importe quecore/models/. Aucune I/O, aucune source, aucun assemblage de livrable.core/builds/n'importe jamaisintegrations/<erp>/. Si un build a besoin d'une source ERP, le caller (typiquementapi/services/ouapi/routers/) la fournit en argument. Cette interdiction préserve ADR-0016 :core/reste ERP-agnostique.integrations/<erp>/n'orchestre pas de logique métier. Son rôle est uniquement d'exposer des sources et sinks conformes aux schémascore/models/. Un fichier commeintegrations/odoo/taxes.pyqui assemble un livrable est une violation — l'assemblage descend encore/builds/, l'I/O Odoo reste enintegrations/odoo/sources.py(ou équivalent).core/loaders/etcore/writers/ne contiennent pas de logique métier. Lecture/écriture seulement..collect()au boundary, pas dans les pipelines. Unpipeline_*retourneLazyFrame[Schema]. La matérialisation est de la responsabilité du build ou du caller. Aligne avec Polars / dbplyr / Ibis. (Migration progressive : lespipeline_*existants qui collectent restent valides en transition ; les nouveaux suivent la règle.)
Comment trancher entre pipeline_* et un build
Pipeline = suite d'opérations pures qui produit un frame composable. Build = produit un livrable final consommable (bundle dataclass ou side-effect).
Critères pratiques :
- Sortie =
LazyFrameouDataFrameunique consommé par d'autres pipelines → pipeline. - Sortie =
@dataclass(frozen=True, slots=True)portant plusieurs frames → build. - Side-effect (écriture DB/fichier, push ERP) → build.
- N entrées hétérogènes (multi-sources) est autorisé dans un pipeline tant que le domaine métier reste cohérent (ex : la CTA d'un PDL, qu'on enrichisse l'
order_nameOdoo ou non, c'est toujours « la CTA »). L'arité d'entrée n'est pas un critère discriminant — aucun framework majeur (Polars, Dagster, Beam, dbt) ne le promeut au rang de catégorie.
Intention différée : core/ports/ le jour d'un 2ème ERP
Aujourd'hui un seul ERP (Odoo) est branché. La règle 2 (« core/builds/ n'importe jamais integrations/<erp>/ ») suffit à préserver l'agnosticité : les sources ERP sont injectées par le caller.
Le jour où un 2ème ERP (Haulogy, autre) apparaît, introduire core/ports/ avec des typing.Protocol pour formaliser les sources/sinks ERP attendus, et faire des adaptateurs integrations/<erp>/ des implémentations explicites de ces protocoles. Pattern ports & adapters / hexagonal. Pas de code Protocol aujourd'hui : ce serait de la machinerie sans seam réel à protéger (cf. principe « one adapter = hypothetical seam ; two adapters = real seam »).
Caveat sur le mot « orchestration »
Dans l'industrie data (Airflow, Dagster jobs, Prefect flows, dlt), « orchestration » désigne le scheduling et l'exécution d'un DAG, pas l'assemblage d'un livrable. Le rôle que nous appelions historiquement « orchestration » (core/orchestrations/contexte_mensuel.py) est plus proche du concept Dagster d'asset ou d'un build artifact.
Choix de vocabulaire retenu : core/builds/. Neutre (pas de collision avec Dagster asset ou Airflow scheduling), lisible (builds.rapport_taxe.rapport_accise(...)), réserve le mot « orchestration » pour le jour où un vrai scheduler est ajouté.
Migration : core/orchestrations/ → core/builds/ en PR mécanique séparée (renommage + imports). Le seul fichier concerné aujourd'hui est contexte_mensuel.py.
Raison
Trois facteurs cumulés :
-
Le vocabulaire actuel ment.
pipeline_ctan'est pas un pipeline (jointure multi-sources hétérogènes),integrations/odoo/taxes.pyn'est pas qu'une intégration (assemble des livrables),core/orchestrations/n'est pas une orchestration au sens industriel. Lire ces noms égare les nouveaux contributeurs (humains et LLM) et masque la dette. -
Le seam le plus rentable est le boundary build/pipeline. Sans règle, on hésite à chaque nouvelle fonction : « pipeline ou orchestration ?
.collect()ou pas ? ». Avec la règle (pipeline= LF→LF composable,build= livrable matérialisé, collect au boundary), chaque nouveau fichier trouve sa place sans discussion. Ancré dans la pratique : Dagsterops/assets, dbtephemeral/table, dltresource/pipeline. -
L'AI-navigability y gagne. Un agent (humain ou LLM) qui lit un fichier de
core/pipelines/sait par construction qu'il ne trouvera ni I/O, ni Odoo, niRapportXxx. Un fichier deintegrations/odoo/est un adapter de source, pas un livrable. Les attentes sont stables, la navigation est rapide.
Alternatives écartées
-
Garder
core/orchestrations/— choix possible (zéro renommage), mais perpétue le mismatch avec la convention Dagster/Airflow. Le jour où on ajoute un scheduler (Dagster, Prefect), le nom devient ambigu. Renommer maintenant pendant que le seul fichier concerné estcontexte_mensuel.pyest ~10 minutes de mécanique. -
Renommer
core/orchestrations/→core/assets/(Dagster-aligned) — explicite mais évoque un catalogue de données qu'on n'a pas.core/builds/est plus neutre et n'engage pas plus que ce qu'on a. -
Discriminer pipelines par arité d'entrée (1→1 vs N→1) — invention locale, aucun framework majeur ne le fait. Beam, Dagster, dbt, Polars utilisent le même primitif pour 1→1 et N→1. Critère écarté.
-
Autoriser
core/builds/à importerintegrations/<erp>/— plus simple à court terme (chaque build appelle directement son ERP), mais casse ADR-0016 :core/ne serait plus ERP-agnostique. La règle « caller injecte les sources ERP » paie une légère ergonomie pour préserver l'agnosticité, sans coût de Protocol tant qu'on n'a qu'un ERP. -
Introduire
core/ports/avec Protocols dès maintenant — over-engineering tant qu'il n'y a qu'un seul ERP (un adapter = pas de seam réel). À introduire le jour du 2ème ERP. L'intention est inscrite dans cet ADR pour ne pas être oubliée.
Conséquences
Drift identifiée à corriger
PRs de cleanup (séparées de l'ADR pour rester reviewables) :
-
Renommage
core/orchestrations/→core/builds/(PR mécanique). Touchecontexte_mensuel.pyet les ~5 imports le référençant (integrations/odoo/taxes.py,integrations/odoo/facturation.py, tests, notebooks). -
Refonte Accise/CTA (PR de design, dans la foulée) :
integrations/odoo/taxes.py→ supprimé. Sources ERP extraites enintegrations/odoo/sources.py(ou intégrées àhelpers.py).core/builds/rapport_taxe.py(nouveau) :RapportTaxe@dataclass(frozen=True, slots=True), primitivesagreger_par_taux/agreger_resume, helperfeuilles_rapport_taxe, assembleursrapport_accise(lignes_lf, trim)/rapport_cta(facturation_df, pdl_df, trim).pipeline_ctaactuel reste un pipeline (jointure tolérée — même domaine métier « taxe d'un PDL »).api/services/taxes_service.py(nouveau) : wire-up sources Odoo + DuckDB + builds.api/routers/taxes.py: transport pur, délègue àtaxes_service.-
Migration
RapportAccise/RapportCtaNamedTuple→@dataclass(frozen=True, slots=True)(drift résiduelle ADR-0018 réglée en passant). -
.collect()sorti despipeline_*existants (PR plus large, hors scope C1). À planifier après stabilisation des builds.
Garde-fou exécutable
Un test d'architecture (tests/architecture/test_imports_par_role.py, à créer) parse les imports de chaque module et échoue sur :
- un fichier de
core/pipelines/**qui importe autre chose quecore/models/,polars,pandera, stdlib ; - un fichier de
core/builds/**qui importeelectricore.integrations.**; - un fichier de
integrations/<erp>/**qui produit un dataclass de livrable (heuristique : retourne un@dataclassdu modulecore/builds/).
Pattern hérité de ADR-0018 : la régression est attrapée par CI au lieu d'attendre la prochaine revue.
Mise à jour CONTEXT.md
La section Concepts pipeline de electricore/core/CONTEXT.md est étendue avec les définitions de Loader, Pipeline, Build, Writer, et la précision sur le caveat « orchestration ». L'entrée Rapport est mise à jour (pipeline d'orchestration → build).
Limites à connaître avant un changement d'échelle
-
core/builds/peut importercore/loaders/(DuckDB). Un build qui charge ses propres données depuis DuckDB est testable mais nécessite une base DuckDB pour les tests d'intégration. Si ce couplage devient gênant, scinder le build : une partie pure (frames en argument) + un helper qui charge depuis loaders. -
La règle « caller injecte les sources ERP » exige discipline côté
api/services/. Un service qui se met à dépendre directement de plusieurs ERP devient un autre type de dette. Le jour où ça arrive, c'est le signal d'introduirecore/ports/. -
pipeline_ctareste un pipeline malgré sa jointure de 2 sources hétérogènes. Choix conscient pour ne pas casser les imports et notebooks existants. La justification (« même domaine métier ») est valable mais subjective ; si une autre fonction frôle cette ambiguïté, statuer cas par cas. -
Le test d'architecture détecte les imports interdits, pas les violations sémantiques (ex : un fichier de
core/pipelines/qui ferait du calcul I/O via une lib qui ne s'appelle paselectricore.loaders). Couverture imparfaite mais utile.
Addendum (juin 2026, issue #146)
electricore/config/ est le socle transverse de la table ci-dessus : stdlib-only
(chargement .env, résolution chemin_base_duckdb(), config Odoo, CSV de taux),
importable par tous les rôles — core/ compris, sans violer ADR-0016
(aucune dépendance ERP ni lib externe). Premier usage : core/loaders/duckdb/,
api/services/, le runner ETL et les tools délèguent la résolution du chemin de la
base DuckDB à chemin_base_duckdb() au lieu de porter chacun leur propre défaut.
Addendum (juin 2026, issues #142–#144)
La migration facturation est terminée : l'assemblage du livrable vit dans
core/builds/rapport_facturation.py, l'I/O Odoo dans
integrations/odoo/sources.py (lignes_factures_du_mois), le wire-up dans
api/services/facturation_service.py — integrations/odoo/facturation.py est
supprimé. Le garde-fou tests/architecture/test_imports_par_role.py gagne une
règle 4 en whitelist : integrations/<erp>/ ne peut importer de
electricore.core que core.models et core.loaders (calque exact de la
table ci-dessus, pendant inverse de test_core_purity).