Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Connexion PostgreSQL utilisée par l'ETL (etl/database.py).
# Ces valeurs correspondent à l'instance locale définie dans docker-compose.yml.
# Copier ce fichier en `.env` : `cp .env.example .env`
PG_USER=postgres
PG_PWD=postgres
PG_DB=ipolitics
PG_HOST=localhost
PG_PORT=5432

# Mettre à True pour logguer toutes les requêtes SQL émises par SQLAlchemy.
PG_ECHO=False
156 changes: 152 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ Voici les différents endpoint:
* députés => `https://parlement.tricoteuses.fr/acteurs`
* votes => `https://parlement.tricoteuses.fr/scrutins`

L'API propose de nombreux endpoints.


[Information sur le chemin d'une loi](https://www.assemblee-nationale.fr/dyn/actualites-accueil-hub/le-parcours-de-la-loi)
Expand All @@ -35,10 +34,23 @@ L'API propose de nombreux endpoints.
- [Installation d'UV](https://docs.astral.sh/uv/)

### Setup

1. Installer les dépendances :
```bash
uv sync
```

2. Créer le fichier `.env` à partir de l'exemple, puis l'adapter si besoin :
```bash
cp .env.example .env
```

3. Démarrer PostgreSQL (instance locale définie dans `docker-compose.yml`, sur le port `5432`) :
```bash
docker compose up -d db
```
Les valeurs par défaut de `.env.example` correspondent à ce conteneur (`postgres`/`postgres`, base `ipolitics`).

### Usages

#### Exécuter des commandes
Expand Down Expand Up @@ -112,16 +124,152 @@ Voici une partie du fichier `./data/dossiers.json`

Je veux rajouter le champ `chambre` dans la DB et faire en sorte que l'ETL l'ajoute de lui-même.
1. Rajouter le champ dans le modèle
```
class User(Base):
```python
class Dossier(Base):
__tablename__ = "dossiers"

uid: Mapped[str] = mapped_column(primary_key=True)
titre: Mapped[str] = mapped_column(String(500))
titre: Mapped[str] = mapped_column(String(1000))
dataset: Mapped[int]
chambre: Mapped[str] = mapped_column(String(5)) # <-------- nouvelle colonne qui porte le même nom que le champ du fichier json
```

Le champ doit porter le même nom sinon l'ETL ne sera pas capable de le trouver.

2. Exécuter `just all`

## Objets parlementaires chargés


| Table | Endpoint | Contenu | Volume (législature 17) |
|---|---|---|---|
| `acteurs` | `/acteurs` | Députés / sénateurs (référentiel trans-législature) | ~3 100 |
| `organes` | `/organes` | Groupes politiques, commissions, assemblées… | ~5 400 |
| `mandats` | `/mandats` | Jointure acteur ↔ organe (appartenance + dates) | ~25 600 |
| `scrutins` | `/scrutins` | Scrutins publics et résultat agrégé (pour/contre/abstentions) | ~8 200 |
| `groupesVotants` | `/groupesVotants` | Résultat d'un scrutin ventilé par groupe politique | ~121 500 * |
| `documents` | `/documents` | Textes (projets/propositions de loi, rapports…) | ~4 500 |
| `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 |
| `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 |

\* `groupesVotants` n'expose pas de filtre `legislature` : la table couvre toutes les législatures
(~98 300 lignes se rattachent à un scrutin de la L17, soit 12 groupes pour chacun des 8 192
scrutins concernés). Se scoper par jointure sur `scrutins`.

Les jointures se font par les colonnes `…RefUid` (références molles, nullable, indexées). Schéma
entité-relation ci-dessous — les boîtes ne montrent que les colonnes clés (PK + FK + quelques
champs parlants) ; la liste complète est dans les modèles `models/`.

```mermaid
erDiagram
dossiers {
string uid PK
string titre
string libelleProcedure
string statut
}
documents {
string uid PK
string dossierRefUid FK
string auteurPrincipalUid FK
text titrePrincipal
string classeLibelle
bool texteLoi
string dateDepot
}
amendements {
string uid PK
string acteurRefUid FK
string groupePolitiqueRefUid FK
string dossierRefUid FK
string documentRefUid FK
string scrutinRefUid FK
string numeroLong
string divisionArticleDesignation
text exposeSommaire
string sortAmendement
string dateDepot
}
acteurs {
string uid PK
string groupeParlementaireUid FK
string nom
string prenom
string chambre
bool actif
}
organes {
string uid PK
string codeType
string libelleAbrev
string positionPolitique
}
mandats {
string uid PK
string acteurRefUid FK
string organeRefUid FK
string typeOrgane
string libQualite
string dateDebut
string dateFin
}
scrutins {
string uid PK
string dossierRefUid FK
string documentRefUid FK
string amendementRefUid FK
string dateScrutin
text objet
string code
int pour
int contre
int abstentions
}
groupesVotants {
string uid PK
string scrutinRefUid FK
string organeRefUid FK
string positionMajoritaire
int pour
int contre
int abstentions
}
auteursDocument {
string uid PK
string documentRefUid FK
string acteurRefUid FK
string qualite
}
coSignatairesDocument {
string uid PK
string documentRefUid FK
string acteurRefUid FK
string dateCosignature
}

dossiers ||--o{ documents : "dossierRefUid"
dossiers ||--o{ amendements : "dossierRefUid"
dossiers ||--o{ scrutins : "dossierRefUid"
documents ||--o{ amendements : "documentRefUid"
documents ||--o{ scrutins : "documentRefUid"
documents ||--o{ auteursDocument : "documentRefUid"
documents ||--o{ coSignatairesDocument : "documentRefUid"
acteurs ||--o{ documents : "auteurPrincipalUid"
acteurs ||--o{ amendements : "acteurRefUid"
acteurs ||--o{ mandats : "acteurRefUid"
acteurs ||--o{ auteursDocument : "acteurRefUid"
acteurs ||--o{ coSignatairesDocument : "acteurRefUid"
organes ||--o{ acteurs : "groupeParlementaireUid"
organes ||--o{ amendements : "groupePolitiqueRefUid"
organes ||--o{ mandats : "organeRefUid"
organes ||--o{ groupesVotants : "organeRefUid"
amendements ||--o{ scrutins : "amendementRefUid"
scrutins ||--o{ amendements : "scrutinRefUid"
scrutins ||--o{ groupesVotants : "scrutinRefUid"
```

Le lien **amendement ↔ scrutin** est natif, et dans les deux sens : `scrutins.amendementRefUid`
pointe vers l'amendement tranché par le scrutin, et `amendements.scrutinRefUid` vers le scrutin
qui a tranché l'amendement. Le second est le plus large (~11 600 amendements contre ~6 800),
un même scrutin pouvant trancher plusieurs amendements identiques. La jointure reste clairsemée :
la plupart des amendements sont tranchés à main levée, sans scrutin public.
43 changes: 37 additions & 6 deletions etl/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@
import models # noqa: F401 # pyright: ignore[reportUnusedImport] # registers all ORM models with Base.metadata
from models.base import Base

# Tables alimentées par l'ETL depuis les fichiers JSON de ./data.
# Seules ces tables sont détruites lors d'un rebuild et parcourues par l'ETL.
# Les tables d'analyse (ajoutées plus tard) en sont volontairement exclues afin
# que leurs résultats survivent à un rebuild et ne soient pas traitées comme des
# fichiers source à charger.
ETL_TABLES = {
"dossiers",
"amendements",
# Objets ajoutés pour les recoupements auteur / groupe / texte / vote agrégé.
"acteurs",
"organes",
"mandats",
"scrutins",
"groupesVotants",
"documents",
"auteursDocument",
"coSignatairesDocument",
}


def _get_db_url():
PG_USER = getenv("PG_USER")
Expand All @@ -24,21 +43,33 @@ def _get_db_url():

def get_engine():
"""Return a configured SQLAlchemy engine"""
PG_ECHO = getenv("PG_ECHO", False)
# getenv renvoie une chaîne : bool("False") vaudrait True, d'où la comparaison explicite.
pg_echo = getenv("PG_ECHO", "").strip().lower() == "true"
pg_url = _get_db_url()
return create_engine(pg_url, poolclass=pool.NullPool, echo=bool(PG_ECHO))
return create_engine(pg_url, poolclass=pool.NullPool, echo=pg_echo)


def _get_etl_tables():
"""Return the schema definitions of the ETL-managed tables only."""
return [table for table in Base.metadata.sorted_tables if table.name in ETL_TABLES]


def create_db():
"""Drop the current DB and recreate from the schema."""
"""Rebuild the ETL-managed tables from the schema.

Only the tables listed in ETL_TABLES are dropped and recreated. Analysis
tables are left untouched so their results survive a rebuild; create_all is
idempotent and (re)creates any missing table without altering existing ones.
"""
print("Creating DB")
engine = get_engine()
print(Base.metadata.tables)
Base.metadata.drop_all(engine)
etl_tables = _get_etl_tables()
print(etl_tables)
Base.metadata.drop_all(engine, tables=etl_tables)
Base.metadata.create_all(engine)
print("Db was created")
return Base.metadata.tables


def get_tables_definition():
return Base.metadata.sorted_tables
return _get_etl_tables()
79 changes: 56 additions & 23 deletions etl/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,35 @@
import httpx

LEGISLATURE = 17
# Liste des apis à télécharger
APIS = ["dossiers", "documents", "amendements"]

MAX_PAGE = 1000
# APIs à télécharger, avec pour chacune : faut-il filtrer par législature ?
# True -> ajoute &legislature=17. Réservé aux endpoints qui exposent ce filtre
# dans l'API ET pour lesquels il est pertinent de se limiter à la L17.
# False -> pas de filtre. Nécessaire pour :
# - les référentiels trans-législature (`acteurs` renvoie même une 500 avec
# le filtre ; `organes` est partagé et on le veut complet pour éviter des
# références orphelines) ;
# - les endpoints qui n'exposent pas de paramètre `legislature`
# (`auteursDocument`, `coSignatairesDocument`, `groupesVotants`) : on les
# scope alors à la L17 par jointure (sur `documents` ou `scrutins`) au
# moment de l'analyse.
APIS = {
"dossiers": True,
"documents": True,
"amendements": True,
"acteurs": False,
"organes": False,
"mandats": True,
"scrutins": True,
"auteursDocument": False,
"coSignatairesDocument": False,
"groupesVotants": False,
}

BATCH_SIZE = 500
BASE_URL = "https://parlement.tricoteuses.fr/"
TIMEOUT = 90
MAX_RETRIES = 3


def save(data, filename):
Expand All @@ -19,40 +42,50 @@ def save(data, filename):
json.dump(data, f)


def get(page, base_url):
params = f"?page={page}&perPage={BATCH_SIZE}&legislature={LEGISLATURE}"
def get(page, base_url, with_legislature):
"""Récupère une page, avec quelques tentatives : l'API ferme parfois la
connexion en cours de route sur les gros volumes."""
params = f"?page={page}&perPage={BATCH_SIZE}"
if with_legislature:
params += f"&legislature={LEGISLATURE}"
url = base_url + params
try:
response = httpx.get(url, timeout=20)
response.raise_for_status()
return response
except Exception as e:
print(f"Error in Download: {e}")
return None
for attempt in range(1, MAX_RETRIES + 1):
try:
response = httpx.get(url, timeout=TIMEOUT)
response.raise_for_status()
return response
except Exception as e:
print(f"\tpage {page} tentative {attempt}/{MAX_RETRIES}: {e}")
sleep(3 * attempt)
return None


def get_api_data(api):
def get_api_data(api, with_legislature):
base_url = BASE_URL + api + "/json"
data = []
for page in range(1, MAX_PAGE):
print("\tpage: ", page)
response = get(page, base_url)
page = 1
while True:
response = get(page, base_url, with_legislature)
if response is None:
break
# On abandonne l'endpoint plutôt que de sauvegarder un fichier tronqué.
raise RuntimeError(
f"Abandon de {api} à la page {page} après {MAX_RETRIES} tentatives"
)

current_batch_data = response.json()
if len(current_batch_data["data"]) == 0:
current_batch_data = response.json()["data"]
if len(current_batch_data) == 0:
break
data.extend(current_batch_data["data"])

data.extend(current_batch_data)
print(f"\t{api} page {page}: +{len(current_batch_data)} (total {len(data)})")
page += 1
sleep(0.3)
return data


def run_download():
for api in APIS:
for api, with_legislature in APIS.items():
print("Fetching ", api)
data = get_api_data(api)
data = get_api_data(api, with_legislature)
save(data, api)


Expand Down
Loading
Loading