diff --git a/Justfile b/Justfile index 542106e..f3e6bb5 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,7 @@ default: just --list -# ENV = {doc | dev | prod} +# ENV = {doc | data | dev | prod} up ENV: #!/bin/bash if [ "{{ENV}}" = "doc" ]; then @@ -10,11 +10,13 @@ up ENV: docker compose -f compose.dev.yml up --detach elif [ "{{ENV}}" = "prod" ]; then docker compose -f compose.prod.yml up --detach --build + elif [ "{{ENV}}" = "data" ]; then + docker compose -f docker/{{ENV}}/compose.yml up --detach else - echo "{{ENV}}: Accepted values are: 'doc|dev|prod'." >&2 + echo "{{ENV}}: Accepted values are: 'doc|data|dev|prod'." >&2 fi -# ENV = {doc | dev | prod} +# ENV = {doc | data | dev | prod} down ENV: #!/bin/bash if [ "{{ENV}}" = "doc" ]; then @@ -23,11 +25,13 @@ down ENV: docker compose -f compose.dev.yml down elif [ "{{ENV}}" = "prod" ]; then docker compose -f compose.prod.yml down + elif [ "{{ENV}}" = "data" ]; then + docker compose -f docker/{{ENV}}/compose.yml down else - echo "{{ENV}}: Accepted values are: 'doc|dev|prod'." >&2 + echo "{{ENV}}: Accepted values are: 'doc|data|dev|prod'." >&2 fi -# ENV = {doc | dev | prod} +# ENV = {doc | data | dev | prod} logs ENV: #!/bin/bash if [ "{{ENV}}" = "doc" ]; then @@ -36,8 +40,10 @@ logs ENV: docker compose -f compose.dev.yml logs --follow elif [ "{{ENV}}" = "prod" ]; then docker compose -f compose.prod.yml logs --follow + elif [ "{{ENV}}" = "data" ]; then + docker compose -f docker/{{ENV}}/compose.yml logs --follow else - echo "{{ENV}}: Accepted values are: 'doc|dev|prod'." >&2 + echo "{{ENV}}: Accepted values are: 'doc|data|dev|prod'." >&2 fi diff --git a/docker/data/compose.yml b/docker/data/compose.yml new file mode 100644 index 0000000..3de5081 --- /dev/null +++ b/docker/data/compose.yml @@ -0,0 +1,38 @@ +services: + # Stockage objet S3-compatible pour la zone bronze du datalake (dev local). + minio: + image: minio/minio:latest + container_name: homepedia-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" # API S3 (endpoint utilisé par le pipeline) + - "9001:9001" # Console web (http://localhost:9001) + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - minio-data:/data + restart: unless-stopped + + # Crée le bucket bronze au démarrage (idempotent) puis se termine. + # Attend que MinIO réponde (boucle de retry) — pas besoin de `mc` en local. + minio-init: + image: minio/mc:latest + container_name: homepedia-minio-init + depends_on: + - minio + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + HOMEPEDIA_S3_BUCKET: ${HOMEPEDIA_S3_BUCKET:-homepedia} + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 \"$$MINIO_ROOT_USER\" \"$$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1; do + echo 'minio-init: en attente de MinIO...'; sleep 1; + done; + mc mb --ignore-existing \"local/$$HOMEPEDIA_S3_BUCKET\"; + echo \"minio-init: bucket prêt -> $$HOMEPEDIA_S3_BUCKET\"; + " + +volumes: + minio-data: diff --git a/documentation/src/dataset_communes.md b/documentation/src/dataset_communes.md new file mode 100644 index 0000000..62235a2 --- /dev/null +++ b/documentation/src/dataset_communes.md @@ -0,0 +1,86 @@ +# Dataset : Communes de France 2025 + +**Source :** [data.gouv.fr](https://www.data.gouv.fr/datasets/communes-et-villes-de-france-en-csv-excel-json-parquet-et-feather/) +**Resource ID :** `f5df602b-3800-44d7-b2df-fa40a0350325` +**Format :** CSV (16.3 MB, UTF-8, séparateur `,`) +**Millésime :** 2025, basé sur la géographie au 1er janvier 2024 +**Licence :** Licence Ouverte v2 (LO v2) + +--- + +## Colonnes retenues + +Sur les 46 colonnes disponibles, 18 ont été sélectionnées pour leur pertinence dans le contexte d'une plateforme d'analyse du marché immobilier français. + +### Identification géographique + +| Colonne | Type | Description | +|---|---|---| +| `code_insee` | `string` | Code commune assigné par l'INSEE. Identifiant principal de la commune, utilisé comme clé de jointure avec les données DVF (Demandes de Valeurs Foncières) et la majorité des jeux de données open data. Format : 5 caractères (ex : `75056` pour Paris, `69123` pour Lyon). | +| `code_postal` | `string` | Code postal principal de la commune. Utilisé pour la recherche par les utilisateurs. Une commune peut avoir plusieurs codes postaux (voir `codes_postaux`). | +| `codes_postaux` | `string` | Liste de tous les codes postaux rattachés à la commune, séparés par des virgules. Utile pour la recherche et la correspondance avec des données issues d'autres sources. | + +### Noms de la commune + +| Colonne | Type | Description | +|---|---|---| +| `nom_standard` | `string` | Nom normalisé de la commune, avec article si applicable (ex : `Le Havre`, `Les Sables-d'Olonne`). Utilisé pour l'affichage dans l'interface. | +| `nom_sans_accent` | `string` | Nom de la commune sans accents, caractères spéciaux ni espaces. Utilisé pour la recherche et l'autocomplétion insensible aux accents (ex : `le-havre`, `les-sables-dolonne`). | + +### Hiérarchie administrative + +| Colonne | Type | Description | +|---|---|---| +| `dep_code` | `string` | Code du département de la commune, assigné par l'INSEE (ex : `75`, `69`, `2A`). Utilisé pour les filtres et regroupements par département. | +| `dep_nom` | `string` | Nom du département (ex : `Paris`, `Rhône`, `Corse-du-Sud`). Affiché dans les filtres et les résultats de recherche. | +| `reg_code` | `string` | Code de la région de la commune, assigné par l'INSEE (ex : `11` pour Île-de-France). Utilisé pour les filtres et les visualisations à l'échelle régionale. | +| `reg_nom` | `string` | Nom de la région (ex : `Île-de-France`, `Auvergne-Rhône-Alpes`). Affiché dans les filtres et les regroupements géographiques. | +| `typecom` | `string` | Type de la commune en version abrégée. Valeurs possibles : `COM` (commune standard), `COMA` (commune associée), `COMD` (commune déléguée), `ARM` (arrondissement municipal, ex : Paris, Lyon, Marseille). Permet de distinguer les entités géographiques dans les visualisations. | + +### Coordonnées géographiques + +| Colonne | Type | Description | +|---|---|---| +| `latitude_centre` | `float` | Latitude du centroïde géographique du territoire communal (WGS84). Utilisé pour le positionnement des marqueurs et la clustering sur la carte MapLibre. | +| `longitude_centre` | `float` | Longitude du centroïde géographique du territoire communal (WGS84). Utilisé conjointement avec `latitude_centre` pour le positionnement cartographique. | + +### Données démographiques et territoriales + +| Colonne | Type | Description | +|---|---|---| +| `population` | `float` | Population municipale de la commune (recensement INSEE). Fournit le contexte du marché local : distinguer les petites communes rurales des grandes agglomérations. | +| `superficie_km2` | `float` | Superficie de la commune en kilomètres carrés. Permet de calculer ou de vérifier la densité de population. | +| `densite` | `float` | Densité de population en habitants par km². Pré-calculé par la source. Utile pour les overlays de carte et les analyses de marché par type de territoire. | + +### Classification du territoire + +| Colonne | Type | Description | +|---|---|---| +| `grille_densite` | `string` | Grille communale de densité à 7 niveaux selon la classification INSEE (ex : `1`, `2`, ..., `7`). | +| `grille_densite_texte` | `string` | Libellé de la grille de densité (ex : `Commune densément peuplée`, `Commune rurale peu dense`, `Bourg rural`). Permet de catégoriser les communes pour filtres et analyses dans l'interface. | + +### Contexte économique et urbain (optionnel) + +| Colonne | Type | Description | +|---|---|---| +| `epci_code` | `string` | Code de l'EPCI (Établissement Public de Coopération Intercommunale) auquel appartient la commune. Permet les regroupements à l'échelle des communautés de communes ou d'agglomération. | +| `epci_nom` | `string` | Nom de l'EPCI (ex : `Métropole du Grand Paris`, `Communauté de communes du Pays de Gex`). Affiché comme niveau intermédiaire entre la commune et le département. | + +--- + +## Colonnes exclues + +Les 28 colonnes suivantes ont été écartées car non pertinentes pour un usage immobilier : + +| Colonnes exclues | Raison | +|---|---| +| `nom_sans_pronom`, `nom_a`, `nom_de`, `nom_standard_majuscule` | Variantes de noms inutiles — `nom_standard` et `nom_sans_accent` suffisent | +| `typecom_texte` | Redondant avec `typecom` | +| `canton_code`, `canton_nom` | Découpage électoral, non pertinent pour l'immobilier | +| `academie_code`, `academie_nom` | Découpage scolaire hors scope | +| `zone_emploi`, `code_insee_centre_zone_emploi` | Trop spécifique pour le MVP | +| `code_unite_urbaine`, `nom_unite_urbaine`, `taille_unite_urbain`, `type_commune_unite_urbain`, `statut_commune_unite_urbain` | Redondant avec `grille_densite` | +| `altitude_moyenne`, `altitude_minimale`, `altitude_maximale` | Non pertinent pour l'immobilier | +| `latitude_mairie`, `longitude_mairie` | `latitude_centre` / `longitude_centre` sont plus représentatifs | +| `gentile` | Non pertinent | +| `url_wikipedia`, `url_villedereve` | Liens externes hors scope | diff --git a/pipeline/.env.example b/pipeline/.env.example new file mode 100644 index 0000000..5c3375d --- /dev/null +++ b/pipeline/.env.example @@ -0,0 +1,15 @@ +# Configuration de l'ingestion bronze -> S3. +# Copier en `.env` et adapter, puis charger les variables avant de lancer un script : +# set -a; source .env; set +a +# uv run python scripts/ingest_transport.py + +# --- Cible S3 (commun MinIO local et AWS réel) --- +HOMEPEDIA_S3_BUCKET=homepedia +HOMEPEDIA_S3_PREFIX=bronze + +# --- MinIO local (via `just up data`) --- +# Laisser AWS_ENDPOINT_URL vide / commenté pour viser AWS réel. +AWS_ENDPOINT_URL=http://localhost:9000 +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_DEFAULT_REGION=us-east-1 diff --git a/pipeline/.python-version b/pipeline/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/pipeline/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/pipeline/homepedia_ingest/__init__.py b/pipeline/homepedia_ingest/__init__.py new file mode 100644 index 0000000..6d23f0d --- /dev/null +++ b/pipeline/homepedia_ingest/__init__.py @@ -0,0 +1,16 @@ +"""Pipeline d'ingestion bronze Homepedia (data.gouv.fr -> S3).""" + +from .bronze import ingest_datagouv_to_s3 +from .datagouv import DownloadResult, ResourceMeta, download, get_resource_meta +from .s3 import make_client, s3_bucket, s3_prefix + +__all__ = [ + "ingest_datagouv_to_s3", + "get_resource_meta", + "download", + "ResourceMeta", + "DownloadResult", + "make_client", + "s3_bucket", + "s3_prefix", +] diff --git a/pipeline/homepedia_ingest/__main__.py b/pipeline/homepedia_ingest/__main__.py new file mode 100644 index 0000000..fa6cad1 --- /dev/null +++ b/pipeline/homepedia_ingest/__main__.py @@ -0,0 +1,36 @@ +"""CLI générique d'ingestion bronze vers S3. + +Ingère n'importe quelle resource data.gouv.fr (CSV ou autre) dans la zone +bronze, sans écrire de script dédié : + + uv run python -m homepedia_ingest + +Variables d'environnement : HOMEPEDIA_S3_BUCKET (obligatoire), HOMEPEDIA_S3_PREFIX +(défaut "bronze"), AWS_ENDPOINT_URL (optionnel, MinIO/LocalStack) + credentials +AWS standard. +""" + +from __future__ import annotations + +import argparse + +from .bronze import ingest_datagouv_to_s3 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="homepedia_ingest", description=__doc__) + parser.add_argument("resource_id", help="ID de la resource data.gouv.fr") + parser.add_argument("dataset", help="Nom logique du dataset (clé de partition)") + args = parser.parse_args() + + res = ingest_datagouv_to_s3(args.resource_id, args.dataset) + if res["status"] == "skipped": + print(f"[bronze] SKIP (déjà présent, même sha256) : s3://{res['bucket']}/{res['key']}") + else: + print(f"[bronze] OK upload : s3://{res['bucket']}/{res['key']}") + print(f"[bronze] {res['size_bytes']} octets, sha256 {res['sha256']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pipeline/homepedia_ingest/bronze.py b/pipeline/homepedia_ingest/bronze.py new file mode 100644 index 0000000..c1caedd --- /dev/null +++ b/pipeline/homepedia_ingest/bronze.py @@ -0,0 +1,96 @@ +"""Ingestion de la couche bronze Homepedia vers S3. + +Principe bronze : on dépose le fichier source **tel quel** dans S3, sans aucune +transformation ni typage (fidélité totale à la source). Le nettoyage et le +typage se feront en couche silver ; la mise en forme API en couche gold. + +Clé S3 (style médaillon, partitionnée par date d'ingestion, compatible +Athena/BigQuery « Hive partitioning ») : + + {prefix}/source=datagouv/dataset=/ingestion_date=YYYY-MM-DD/__ + +Des métadonnées de lignage sont attachées à l'objet S3 (source-url, resource-id, +dataset-id, sha256, ingested-at), relisibles via head_object. +""" + +from __future__ import annotations + +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from .datagouv import download, get_resource_meta +from .s3 import head_metadata, make_client, put_file, s3_bucket, s3_prefix + + +def _object_key( + prefix: str, dataset: str, ingestion_date: str, resource_id: str, filename: str +) -> str: + return ( + f"{prefix}/source=datagouv/dataset={dataset}" + f"/ingestion_date={ingestion_date}/{resource_id}__{filename}" + ) + + +def ingest_datagouv_to_s3(resource_id: str, dataset: str) -> dict: + """Télécharge une resource data.gouv.fr et la dépose brute dans S3 (bronze). + + Idempotent : si l'objet existe déjà à la même clé avec le même SHA-256, + l'upload est sauté. + + Args: + resource_id: identifiant de la resource data.gouv.fr. + dataset: nom logique du jeu de données (clé de partition, ex. "transport"). + + Returns: + Dictionnaire récapitulatif : status (uploaded|skipped), bucket, key, + sha256, size_bytes, source_url, ingested_at. + """ + # Config S3 d'abord : fail-fast si HOMEPEDIA_S3_BUCKET manque (avant tout réseau). + bucket = s3_bucket() + prefix = s3_prefix() + client = make_client() + + meta = get_resource_meta(resource_id) + now = datetime.now(timezone.utc) + ingestion_date = now.strftime("%Y-%m-%d") + ingested_at = now.isoformat() + + with tempfile.TemporaryDirectory() as tmp: + dl = download(meta.url, Path(tmp)) + key = _object_key(prefix, dataset, ingestion_date, resource_id, dl.path.name) + + existing = head_metadata(client, bucket, key) + if existing is not None and existing.get("sha256") == dl.sha256: + return { + "status": "skipped", + "bucket": bucket, + "key": key, + "sha256": dl.sha256, + "size_bytes": dl.size_bytes, + "source_url": meta.url, + "ingested_at": ingested_at, + } + + # Métadonnées de lignage attachées à l'objet S3 (clés en tirets pour + # rester portables AWS / MinIO ; valeurs forcément des chaînes). + object_metadata = { + "source": "datagouv", + "dataset": dataset, + "resource-id": resource_id, + "dataset-id": meta.dataset_id, + "source-url": meta.url, + "sha256": dl.sha256, + "ingested-at": ingested_at, + } + put_file(client, bucket, key, dl.path, metadata=object_metadata) + + return { + "status": "uploaded", + "bucket": bucket, + "key": key, + "sha256": dl.sha256, + "size_bytes": dl.size_bytes, + "source_url": meta.url, + "ingested_at": ingested_at, + } diff --git a/pipeline/homepedia_ingest/datagouv.py b/pipeline/homepedia_ingest/datagouv.py new file mode 100644 index 0000000..b991e2f --- /dev/null +++ b/pipeline/homepedia_ingest/datagouv.py @@ -0,0 +1,93 @@ +"""Client minimal pour l'API data.gouv.fr et téléchargement de resources. + +Utilise uniquement la stdlib (urllib) pour éviter une dépendance réseau +supplémentaire. Le téléchargement est streamé sur disque et calcule le SHA-256 +du fichier source au passage (traçabilité de la couche bronze). +""" + +from __future__ import annotations + +import hashlib +import json +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +API_RESOURCE_URL = "https://www.data.gouv.fr/api/2/datasets/resources/{resource_id}/" +_USER_AGENT = "Homepedia-bronze-ingest/1.0 (+https://github.com/T-DAT-902-Homepedia)" +_HTTP_TIMEOUT = 300 + + +@dataclass(frozen=True) +class ResourceMeta: + """Métadonnées d'une resource data.gouv.fr.""" + + resource_id: str + dataset_id: str + title: str + url: str + format: str + filesize: int | None + checksum_type: str | None + checksum_value: str | None + + +@dataclass(frozen=True) +class DownloadResult: + """Résultat d'un téléchargement : fichier local, taille et empreinte.""" + + path: Path + size_bytes: int + sha256: str + + +def _http_get_json(url: str) -> dict: + req = urllib.request.Request( + url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"} + ) + with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: + return json.load(resp) + + +def get_resource_meta(resource_id: str) -> ResourceMeta: + """Résout l'URL et les métadonnées d'une resource via l'API data.gouv.fr.""" + payload = _http_get_json(API_RESOURCE_URL.format(resource_id=resource_id)) + resource = payload.get("resource", payload) + checksum = resource.get("checksum") or {} + url = resource.get("url") + if not url: + raise ValueError(f"Aucune URL trouvée pour la resource {resource_id}") + return ResourceMeta( + resource_id=resource_id, + dataset_id=payload.get("dataset_id", ""), + title=resource.get("title", ""), + url=url, + format=(resource.get("format") or "").lower(), + filesize=resource.get("filesize"), + checksum_type=checksum.get("type"), + checksum_value=checksum.get("value"), + ) + + +def download(url: str, dest_dir: Path, *, chunk_size: int = 1 << 20) -> DownloadResult: + """Télécharge `url` dans `dest_dir` en streaming, renvoie taille + SHA-256. + + L'écriture passe par un fichier temporaire puis un rename atomique pour + éviter un fichier partiel en cas d'interruption. + """ + dest_dir.mkdir(parents=True, exist_ok=True) + filename = url.rstrip("/").split("/")[-1] or "download" + dest = dest_dir / filename + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + sha = hashlib.sha256() + size = 0 + with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: + with tempfile.NamedTemporaryFile(dir=dest_dir, delete=False) as tmp: + tmp_path = Path(tmp.name) + while chunk := resp.read(chunk_size): + tmp.write(chunk) + sha.update(chunk) + size += len(chunk) + tmp_path.replace(dest) + return DownloadResult(path=dest, size_bytes=size, sha256=sha.hexdigest()) diff --git a/pipeline/homepedia_ingest/s3.py b/pipeline/homepedia_ingest/s3.py new file mode 100644 index 0000000..139fec3 --- /dev/null +++ b/pipeline/homepedia_ingest/s3.py @@ -0,0 +1,82 @@ +"""Client S3 minimal pour la zone bronze Homepedia (basé sur boto3). + +Configuration par variables d'environnement : + HOMEPEDIA_S3_BUCKET (obligatoire) nom du bucket S3 cible + HOMEPEDIA_S3_PREFIX (défaut "bronze") préfixe racine des objets bronze + AWS_ENDPOINT_URL (optionnel) endpoint S3 custom — permet de pointer un + MinIO / LocalStack en local sans compte AWS réel + +Les credentials AWS suivent la chaîne standard boto3 (~/.aws/credentials, +variables AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, profil, rôle IAM, ...). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +_NOT_FOUND_CODES = {"404", "NoSuchKey", "NotFound"} + + +def s3_bucket() -> str: + """Nom du bucket S3 cible (variable HOMEPEDIA_S3_BUCKET, obligatoire).""" + bucket = os.environ.get("HOMEPEDIA_S3_BUCKET") + if not bucket: + raise RuntimeError( + "Variable d'environnement HOMEPEDIA_S3_BUCKET manquante " + "(nom du bucket S3 cible pour la zone bronze)." + ) + return bucket + + +def s3_prefix() -> str: + """Préfixe racine des objets bronze (variable HOMEPEDIA_S3_PREFIX, défaut 'bronze').""" + return os.environ.get("HOMEPEDIA_S3_PREFIX", "bronze").strip("/") + + +def make_client(): + """Client S3 boto3. Honore AWS_ENDPOINT_URL si défini (MinIO/LocalStack). + + Sur un endpoint custom, on force le path-style addressing : sinon boto3 tente + le virtual-host style (`http://.`), qui ne résout pas en + local (MinIO/LocalStack). Sur AWS réel (endpoint absent), on laisse le défaut. + """ + endpoint = os.environ.get("AWS_ENDPOINT_URL") or None + config = Config(s3={"addressing_style": "path"}) if endpoint else None + return boto3.client("s3", endpoint_url=endpoint, config=config) + + +def head_metadata(client, bucket: str, key: str) -> dict | None: + """Métadonnées custom de l'objet s'il existe, sinon None. + + Sert au contrôle d'idempotence (comparaison du sha256 avant ré-upload). + """ + try: + resp = client.head_object(Bucket=bucket, Key=key) + except ClientError as exc: + if exc.response.get("Error", {}).get("Code") in _NOT_FOUND_CODES: + return None + raise + return resp.get("Metadata", {}) + + +def put_file( + client, + bucket: str, + key: str, + path: Path, + *, + metadata: dict, + content_type: str = "text/csv", +) -> None: + """Dépose un fichier local dans S3 (upload multipart automatique si gros).""" + client.upload_file( + str(path), + bucket, + key, + ExtraArgs={"Metadata": metadata, "ContentType": content_type}, + ) diff --git a/pipeline/pyproject.toml b/pipeline/pyproject.toml new file mode 100644 index 0000000..bfe78cb --- /dev/null +++ b/pipeline/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "homepedia-ingest" +version = "0.1.0" +description = "Pipeline d'ingestion bronze Homepedia : téléchargement des CSV data.gouv.fr vers la zone bronze (S3)" +requires-python = ">=3.13" +dependencies = [ + "boto3>=1.43.17", +] diff --git a/pipeline/scripts/ingest_politique.py b/pipeline/scripts/ingest_politique.py new file mode 100644 index 0000000..53dfe5c --- /dev/null +++ b/pipeline/scripts/ingest_politique.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Ingestion bronze des résultats des municipales 2026 (data.gouv.fr -> S3). + +Dépose dans la zone bronze les résultats par commune des deux tours (Ministère +de l'intérieur). Le maire et l'orientation politique ne sont PAS dans les +fichiers : ils seront DÉRIVÉS en couche silver (maire = tête de la liste +gagnante de la commune ; orientation = nuance de cette liste). + +Usage (depuis pipeline/) : + uv run python scripts/ingest_politique.py + +Variables d'environnement : HOMEPEDIA_S3_BUCKET (+ AWS_* / AWS_ENDPOINT_URL). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Rend le package `homepedia_ingest` importable quand on lance le script directement. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from homepedia_ingest import ingest_datagouv_to_s3 # noqa: E402 + +DATASET = "politique" +RESOURCES = { + "4feeef01-24f7-4d5a-914f-8aa806f31ec2": "municipales 2026 — résultats communes tour 1", + "6ff67a28-01bf-459e-beca-dd7aa8132dc1": "municipales 2026 — résultats communes tour 2", +} + + +def main() -> int: + for resource_id, label in RESOURCES.items(): + print(f"[bronze:politique] {label} — {resource_id}") + res = ingest_datagouv_to_s3(resource_id, DATASET) + status = "SKIP (déjà présent)" if res["status"] == "skipped" else "OK upload" + print(f" -> {status} : s3://{res['bucket']}/{res['key']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pipeline/scripts/ingest_transport.py b/pipeline/scripts/ingest_transport.py new file mode 100644 index 0000000..0b7f2ef --- /dev/null +++ b/pipeline/scripts/ingest_transport.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Ingestion bronze des données « transport en commun » (data.gouv.fr -> S3). + +Dépose dans la zone bronze les 2 ressources du dataset « Nombre de stations de +transports en commun pour 1000 habitants ». Elles sont utilisées séparément en +aval (aucune jointure entre elles) : + - nb-stations-tc-com.csv : nb d'arrêts par type, agrégé par commune (stats de couverture) + - tc-coord.csv : position GPS de chaque arrêt (carte, icône par type) + +Usage (depuis pipeline/) : + uv run python scripts/ingest_transport.py + +Variables d'environnement : HOMEPEDIA_S3_BUCKET (+ AWS_* / AWS_ENDPOINT_URL). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Rend le package `homepedia_ingest` importable quand on lance le script directement. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from homepedia_ingest import ingest_datagouv_to_s3 # noqa: E402 + +DATASET = "transport" +RESOURCES = { + "2a1b349d-4d96-4bd5-8b83-bf8e501fb96b": "nb-stations-tc-com (couverture par commune)", + "37ecfc6a-ee47-44eb-bdb3-edbefcbb0eac": "tc-coord (coordonnées des arrêts)", +} + + +def main() -> int: + for resource_id, label in RESOURCES.items(): + print(f"[bronze:transport] {label} — {resource_id}") + res = ingest_datagouv_to_s3(resource_id, DATASET) + status = "SKIP (déjà présent)" if res["status"] == "skipped" else "OK upload" + print(f" -> {status} : s3://{res['bucket']}/{res['key']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pipeline/uv.lock b/pipeline/uv.lock new file mode 100644 index 0000000..c2bf722 --- /dev/null +++ b/pipeline/uv.lock @@ -0,0 +1,93 @@ +version = 1 +revision = 1 +requires-python = ">=3.13" + +[[package]] +name = "boto3" +version = "1.43.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/6b/80f6bf4f6253c2221c70a2b70af72038bb6e8820ac4547f2ba7d4efcb6be/boto3-1.43.17.tar.gz", hash = "sha256:8cf48babdd52ff0e2d891dc661143780b361d3776a3be06cd719da0696995074", size = 113167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1e/30b218998dee295873f33c591bb5daf08c42ec27e5fb0ebb13977677e96f/boto3-1.43.17-py3-none-any.whl", hash = "sha256:f6b3862a0b14e237f9323223ee76b0563e87a6bbe6d94a42e7b008a901ba8950", size = 140538 }, +] + +[[package]] +name = "botocore" +version = "1.43.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/37/a9227caa820189bce55564b6cfc9bbf22f6c984e6b0f0c614348424fb84a/botocore-1.43.17.tar.gz", hash = "sha256:27f4ecb80cf1e5be70415fc4a4d3db3907d41ef8178c9df822364f275427d375", size = 15417107 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/ff/1625713b2ecac9f9bb65c7a51e71cb206b3089ba38f86ba5eff34e947176/botocore-1.43.17-py3-none-any.whl", hash = "sha256:499af7c942ecfd404322974e82c6b5d05a8ea16e9f19320b353e16f401adc5b4", size = 15097131 }, +] + +[[package]] +name = "homepedia-ingest" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "boto3" }, +] + +[package.metadata] +requires-dist = [{ name = "boto3", specifier = ">=1.43.17" }] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "s3transfer" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +]