<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ brooklyn - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ brooklyn - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 30 Aug 2026 21:24:25 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/brkln/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ The ETL Pipeline Handbook: How to Build a Production-Grade Pipeline in Python ]]>
                </title>
                <description>
                    <![CDATA[ Tracking flood risk takes one unglamorous but essential thing: clean and structured data. In this tutorial, you'll build a data pipeline yourself. You'll create a Python ETL (Extract, Transform, Load) ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-etl-pipeline-handbook-how-to-build-a-production-grade-pipeline-in-python/</link>
                <guid isPermaLink="false">6a679921f4d9ad6845fede20</guid>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ETL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ python projects ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pandas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Pipeline ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ brooklyn ]]>
                </dc:creator>
                <pubDate>Mon, 27 Jul 2026 17:45:05 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/36906090-d056-4207-8632-fcdf35843018.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Tracking flood risk takes one unglamorous but essential thing: clean and structured data.</p>
<p>In this tutorial, you'll build a data pipeline yourself. You'll create a Python ETL (Extract, Transform, Load) pipeline that pulls daily water-level readings from <a href="https://hubeau.eaufrance.fr/">Hub'Eau</a>, France's official open water-data API. Then, you'll clean that data and publish it as a public dataset, just like the <a href="https://www.kaggle.com/code/grimespoint/paris-flood-dataset-weekly-updater">live version</a> does.</p>
<p>This tutorial is based on a real pipeline that runs once a week, on a schedule, and it keeps the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">Paris Flood Dataset</a> updated automatically.</p>
<p>You won't just copy and paste code, though. The real goal is to understand <em>why</em> the pipeline works the way it does. You'll walk through the design decisions that separate a script meant to run once from a script that keeps working, unattended, for years.</p>
<p>You can code along with this <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">notebook</a>. For most of the tutorial, the pipeline runs on <strong>simulated (mock) API data</strong>. This lets you run every cell safely, without hammering a real server. A later section shows how to switch to the live API.</p>
<p>By the end, you'll be able to:</p>
<ul>
<li><p>Explain and implement the Extract, Transform, Load pattern</p>
</li>
<li><p>Manage configuration with Python <code>@dataclass</code> instead of scattering constants everywhere</p>
</li>
<li><p>Write API-fetching code that survives network failures and paginated responses</p>
</li>
<li><p>Apply robust type-coercion so one bad row can't crash a whole pipeline run</p>
</li>
<li><p>Deduplicate and merge incremental data safely</p>
</li>
<li><p>Wire everything into a single, idempotent, schedulable <code>main()</code> entry point</p>
</li>
</ul>
<h3 id="heading-table-of-contents">Table of Contents:</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-the-design-logic">Part 1: The Design Logic</a></p>
<ul>
<li><p><a href="#heading-what-is-an-etl-pipeline">What is an ETL pipeline?</a></p>
</li>
<li><p><a href="#heading-the-architecture-at-a-glance">The architecture, at a glance</a></p>
</li>
<li><p><a href="#heading-two-patterns-that-make-it-production-grade">Two patterns that make it production-grade</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-setup-and-dependencies">Part 2: Setup and Dependencies</a></p>
</li>
<li><p><a href="#heading-part-3-manage-configuration-with-dataclasses">Part 3: Manage Configuration with Dataclasses</a></p>
<ul>
<li><p><a href="#heading-why-bother-with-a-config-layer-at-all">Why bother with a config layer at all?</a></p>
</li>
<li><p><a href="#heading-code-level-walkthrough">Code-level walkthrough</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-the-extraction-step">Part 4: The Extraction Step</a></p>
<ul>
<li><p><a href="#heading-graceful-file-loading">Graceful file loading</a></p>
</li>
<li><p><a href="#heading-incremental-update-logic">Incremental update logic</a></p>
</li>
<li><p><a href="#heading-simulate-the-api">Simulate the API</a></p>
</li>
<li><p><a href="#heading-fetch-data-for-real">Fetch data for real</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-5-the-transform-step">Part 5: The Transform Step</a></p>
<ul>
<li><p><a href="#heading-type-parsing-and-graceful-coercion">Type parsing and graceful coercion</a></p>
</li>
<li><p><a href="#heading-schema-translation-with-bidirectional-mappings">Schema translation with bidirectional mappings</a></p>
</li>
<li><p><a href="#heading-compute-flood-alerts">Compute flood alerts</a></p>
</li>
<li><p><a href="#heading-column-ordering">Column ordering</a></p>
</li>
<li><p><a href="#heading-deduplication">Deduplication</a></p>
</li>
<li><p><a href="#heading-put-it-all-together-in-postprocess">Put it all together inpostprocess()</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-6-the-load-step">Part 6: The Load Step</a></p>
<ul>
<li><p><a href="#heading-design-logic">Design logic</a></p>
</li>
<li><p><a href="#heading-code-level-walkthrough">Code-level walkthrough</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-7-assemble-the-full-pipeline">Part 7: Assemble the Full Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-global-rehearsal-mock-mode">The global rehearsal (mock mode)</a></p>
</li>
<li><p><a href="#heading-main-pipeline-orchestration">main(): pipeline orchestration</a></p>
</li>
<li><p><a href="#heading-the-if-name-main-guard">Theif name == "main":guard</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-8-go-live-and-switch-to-the-real-api">Part 8: Go Live and Switch to the Real API</a></p>
<ul>
<li><a href="#heading-post-run-validation">Post-run validation</a></li>
</ul>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p><strong>Python 3.10+</strong>. The code uses type hints and dataclasses. These work from Python 3.7 onward, but 3.10+ is best.</p>
</li>
<li><p><a href="https://leetcode.com/studyplan/introduction-to-pandas/"><strong>Knowledge of pandas DataFrames</strong></a>: read data, filter rows, and basic column operations.</p>
</li>
<li><p>Comfort with <strong>functions and basic OOP</strong> (<a href="https://realpython.com/python3-object-oriented-programming/">Object-Oriented programming</a>) in Python. Don't worry, this guide explains every non-obvious piece as you go.</p>
</li>
<li><p>Optional: a free <a href="https://www.kaggle.com/">Kaggle</a> account and the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a>, only if you want to run the final publishing step for real.</p>
</li>
</ul>
<p>Install the dependencies:</p>
<pre><code class="language-bash">pip install requests pandas numpy ipykernel
</code></pre>
<p>You'll do the work inside a <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">Jupyter notebook</a>. Download the <code>.ipynb</code> file from Kaggle, or click "Copy and Edit" to work directly on Kaggle. You'll need a Kaggle account for that.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/681c54fc-c12a-4c01-83ba-17368d4ccc49.png" alt="The menu button that provides options to download the data engineering follow along notebook on Kaggle." style="display:block;margin:0 auto" width="695" height="502" loading="lazy">

<p><strong>Optional:</strong> If you plan to run the notebook locally, install the <code>notebook</code> package too:</p>
<pre><code class="language-bash">pip install notebook
</code></pre>
<h2 id="heading-part-1-the-design-logic">Part 1: The Design Logic</h2>
<p>Before you touch a single line of code, we'll spend three minutes on <em>why</em> the pipeline is shaped this way.</p>
<p>This is the <strong>big-picture view</strong>. Every code-level decision later traces back to one of these ideas. Read this section even if you skim everything else.</p>
<h3 id="heading-what-is-an-etl-pipeline">What is an ETL Pipeline?</h3>
<p>ETL stands for <strong>Extract, Transform, Load</strong>. It's the standard pattern for moving data from a source to a destination in a reliable, repeatable way.</p>
<ul>
<li><p><strong>Extract</strong>: pull data from a source, like an API, a database, or files.</p>
</li>
<li><p><strong>Transform</strong>: clean, standardize, enrich, and validate the data.</p>
</li>
<li><p><strong>Load</strong>: write the result to a destination, like a warehouse, a CSV, or a public platform.</p>
</li>
</ul>
<p>Here's how those three stages map onto this project:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>What happens here</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Extract</strong></td>
<td>Load existing data if it exists, then call the Hub'Eau API (via <code>requests</code>) for each gauging station</td>
</tr>
<tr>
<td><strong>Transform</strong></td>
<td>Translate French columns and entries to English, fix data types, remove duplicates</td>
</tr>
<tr>
<td><strong>Load</strong></td>
<td>Write a CSV and a metadata file, then publish to Kaggle via the CLI</td>
</tr>
</tbody></table>
<h3 id="heading-the-architecture-at-a-glance">The Architecture, at a Glance</h3>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/6bede708-bdb9-4cae-bfd8-1dc1aa0348f6.png" alt="Schema of a simple ETL pipeline." style="display:block;margin:0 auto" width="2040" height="900" loading="lazy">

<p>Notice that "Extract" already touches two different sources: the <em>existing</em> dataset (what you already have) and the <em>new</em> data from the API. That distinction is the seed of the next idea.</p>
<h3 id="heading-two-patterns-that-make-it-production-grade">Two Patterns that Make it Production-Grade</h3>
<p>There two patterns that make this pipeline safe to run unattended, scheduled, for years.</p>
<h4 id="heading-1-idempotency">1. Idempotency</h4>
<p><a href="https://en.wikipedia.org/wiki/Idempotence"><strong>Idempotence</strong></a> means when the same operation runs twice it gives the same result as if it runs once. Deduplication is what makes this pipeline idempotent. If the scheduler accidentally triggers twice, or a network retry fetches the same day again, the second run won't create duplicate rows. This matters a lot for anything that runs on a schedule with no one watching it.</p>
<h4 id="heading-2-incremental-loading">2. Incremental loading</h4>
<p>A naïve pipeline would re-download the <em>entire</em> history on every run. That's slow, it wastes your API quota, and it's fragile: the more data you transfer, the more chances something fails.</p>
<p>An <strong>incremental</strong> pipeline avoids this. Instead, it:</p>
<ul>
<li><p>Checks the most recent date already in the dataset</p>
</li>
<li><p>Requests only the data <em>from that date onward</em></p>
</li>
<li><p>Merges the new records into the existing dataset</p>
</li>
</ul>
<p>You'll find this logic built explicitly in <a href="#heading-incremental-update-logic"><code>determine_update_range</code></a>.</p>
<p>Always ask yourself: <strong>"Do I actually need to do this?"</strong> That one habit separates a fragile script from a production pipeline. For example, every "expensive" or "external" step in this pipeline (network calls, disk writes, publishing) is guarded by a cheap, local check first.</p>
<h2 id="heading-part-2-setup-and-dependencies">Part 2: Setup and Dependencies</h2>
<p>Here's the import block. It looks unremarkable, but how it's organized is itself a best practice worth calling out.</p>
<pre><code class="language-python"># Standard library imports
import json
import os
import random
import subprocess
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

# Third-party libraries
import numpy as np
import pandas as pd
import requests

# Display config for notebooks
pd.set_option('display.max_columns', None)   # All columns will show
pd.set_option('display.max_colwidth', None)  # Prevents cutting long column text with ...
</code></pre>
<p><strong>Code-level notes:</strong></p>
<ul>
<li><p>Imports fall into three blocks: <strong>standard library, then third-party, then local</strong>, with blank lines between them. This follows <a href="https://peps.python.org/pep-0008/#imports">PEP 8's import-ordering convention</a>. Most <a href="https://en.wikipedia.org/wiki/Pretty-printing">auto-formatters</a> (<code>isort</code>, <code>ruff</code>) enforce this same grouping.</p>
</li>
<li><p>Nothing is imported with <code>from module import *</code>. That syntax pollutes the current namespace. It makes it hard to trace where a name came from when someone debugs the code six months later. Python style guides echo this advice, including <a href="https://google.github.io/styleguide/pyguide.html">Google's Python Style Guide</a>.</p>
</li>
<li><p>The <code>pd.set_option(...)</code> calls exist purely for <em>notebook readability</em>, so wide DataFrames don't get truncated with <code>...</code>. They have zero effect on the pipeline's logic. You'd typically remove or scope them differently in a <code>.py</code> script.</p>
</li>
</ul>
<h2 id="heading-part-3-manage-configuration-with-dataclasses">Part 3: Manage Configuration with Dataclasses</h2>
<h3 id="heading-why-bother-with-a-config-layer-at-all">Why Bother with a Config Layer at All?</h3>
<p>Every pipeline has <strong>knobs</strong>: which stations to monitor, what the flood threshold is, and where to publish. The bad approach sprinkles these values as literals throughout the code as you write it. You end up with an <code>if level &gt; 6000:</code> buried three functions deep. Then changing <em>any</em> setting means hunting through the whole file, and it's easy to update one spot and miss another.</p>
<p>The fix: <strong>centralize all settings in one place.</strong> Python's <a href="https://docs.python.org/3/library/dataclasses.html"><code>@dataclass</code></a> decorator is a natural fit for that.</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Why it matters</th>
</tr>
</thead>
<tbody><tr>
<td>Auto-generated <code>__init__</code>, <code>__repr__</code>, <code>__eq__</code></td>
<td>You don't need to write them yourself</td>
</tr>
<tr>
<td><a href="https://www.geeksforgeeks.org/python/type-hints-in-python/">Type hints</a></td>
<td>Gives you IDE autocomplete and self-documenting code</td>
</tr>
<tr>
<td>Optional <code>frozen=True</code></td>
<td>Gives you <a href="https://stackoverflow.com/questions/66194804/what-does-frozen-mean-for-dataclasses">true immutability</a> if you want config knobs that can't change after creation</td>
</tr>
<tr>
<td><code>__post_init__</code> hook</td>
<td>Validates or computes derived fields once, right after construction</td>
</tr>
</tbody></table>
<p>Compare that to a configuration written as a plain <code>dict</code>:</p>
<pre><code class="language-python">config = {
    "stations": ["STN001", "STN002"],
    "flood_threshold": 6000,
    "publish_url": "https://example.com/alerts",
    "retry_count": 2,
    "timeout_seconds": 5,
}
</code></pre>
<p>A plain dict gives you none of that: no immutability, no type checking, and no autocomplete.</p>
<h3 id="heading-code-level-walkthrough">Code-Level Walkthrough</h3>
<p>The pipeline defines three configuration classes. Each one has <em>a single responsibility</em>: API details, station rules, and publishing destination. Each class becomes one <strong>module-level singleton instance</strong>. Every other function in the pipeline reads from these singletons.</p>
<pre><code class="language-python">@dataclass
class APIConfig:
    """API configuration for HubEau data fetching.

    Think of this as the "address book" for the API.
    """
    use_mock: bool = True
    base_url: str = "https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab"
    metric: str = "HIXnJ"  # Daily max water level (elaborated observations)
    # Pagination: fetch 20k records per request (API Limit)
    max_per_page: int = 20000
    timeout_seconds: int = 60  # Network timeout

    def __post_init__(self):
        """Validate configuration after initialization."""
        if self.max_per_page &lt;= 0:
            raise ValueError("max_per_page must be positive")
        if self.timeout_seconds &lt;= 0:
            raise ValueError("timeout_seconds must be positive")
</code></pre>
<p>Notice the validation inside <code>__post_init__</code>. It runs right after the auto-generated <code>__init__</code>. A misconfigured <code>APIConfig(max_per_page=-1)</code> fails loudly and immediately at startup, instead of surfacing as a bug later during an actual pipeline run.</p>
<pre><code class="language-python">@dataclass
class StationConfig:
    """Station monitoring configuration.

    The 'what' of data collection: which stations, what's a flood?
    """
    station_codes: List[str] = field(default_factory=lambda: [
        "F700000109", "F700000110", "F700000111",
        "F700000102", "F700000103",
    ])
    flood_threshold_mm: int = 6000  # Flood alert threshold
    earliest_date: str = "1900-01-01"  # How far back to go
</code></pre>
<p><strong>The mutable-default trap:</strong> Look closely at <code>station_codes</code>. It isn't written as <code>station_codes: List[str] = [...]</code>. That's deliberate, and it dodges one of the most common gotchas in Python. If you use a plain mutable object (a list, dict, or set) as a default argument or dataclass field, <strong>every instance shares the same underlying object</strong>. Mutate it on one instance, and you silently mutate it everywhere else too.</p>
<p>Stack Overflow covers this at length in its <a href="https://stackoverflow.com/questions/1132941/least-astonishment-and-the-mutable-default-argument">"least astonishment" mutable-default-argument discussion</a>, as does <a href="https://realpython.com/python-optional-arguments/">Real Python's guide to optional arguments</a>. The fix is <code>field(default_factory=...)</code>. It calls a <em>fresh</em> factory function (here, a <code>lambda</code>) for every new instance, so each one gets its own independent list.</p>
<p>Explanation:</p>
<pre><code class="language-python"># Bad: shared mutable default
@dataclass
class BadConfig:
    station_codes: list[str] = []

a = BadConfig()
b = BadConfig()

a.station_codes.append("ALERT")
print(a.station_codes)  # ['ALERT']
print(b.station_codes)  # ['ALERT']  &lt;-- same list!!


# Good: fresh list per instance
@dataclass
class GoodConfig:
    station_codes: list[str] = field(default_factory=list)

x = GoodConfig()
y = GoodConfig()

x.station_codes.append("ALERT")
print(x.station_codes)  # ['ALERT']
print(y.station_codes)  # []  &lt;-- independent list
</code></pre>
<p>And the last Config singleton, the Kaggle settings:</p>
<pre><code class="language-python">@dataclass
class KaggleConfig:
    """Kaggle dataset publishing configuration."""
    dataset_slug: str = "grimespoint/paris-flood-dataset"
    input_csv: str = "kaggle/input/datasets/{slug}/paris_flood_dataset.csv"
    output_dir: Path = field(default_factory=lambda: Path("kaggle/working/kaggle_dataset"))
    mock_output_dir: Path = field(default_factory=lambda: Path("mock_output"))
    output_filename: str = "paris_flood_dataset.csv"
    mock_output_filename: str = "mock_flood_dataset.csv"
    metadata_filename: str = "dataset-metadata.json"

    # Metadata
    title: str = "Paris flood dataset"
    keywords: list = field(default_factory=lambda: [
        "tabular", "weather and climate", "environment", "europe", "time series analysis"
    ])
    geospatial_coverage: str = "Paris, France"
    update_frequency: str = "Weekly"
    license_name: str = "CC0-1.0"

    # Computed fields (set in __post_init__)
    output_csv_path: Path = field(init=False)
    metadata_path: Path = field(init=False)

    def __post_init__(self):
        """Compute derived paths after initialization."""
        self.input_csv = self.input_csv.format(slug=self.dataset_slug)
        self.output_csv_path = self.output_dir / self.output_filename
        self.metadata_path = self.output_dir / self.metadata_filename
        self.mock_output_filename = self.mock_output_dir / self.mock_output_filename

# Initialize configs - module-level singletons
API_CONFIG = APIConfig()
STATION_CONFIG = StationConfig()
KAGGLE_CONFIG = KaggleConfig()
</code></pre>
<p>This is the most common use of <code>__post_init__</code>. <code>output_csv_path</code> and <code>metadata_path</code> are marked <code>field(init=False)</code>, so you can't set them directly through the constructor. Instead, <code>__post_init__</code> computes them from other fields (<code>output_dir</code> and <code>output_filename</code>).</p>
<p>Use this pattern for <strong>derived, computed values</strong>: compute them once, in one place, instead of recomputing <code>output_dir / output_filename</code> every time you need the path elsewhere in the codebase.</p>
<p>See <a href="https://realpython.com/python-data-classes/#comparing-cards">Real Python's data classes guide</a> or the data classes chapter of O'Reilly's <em>Fluent Python</em> for more on this pattern.</p>
<p><strong>Tip:</strong> the auto-generated <code>__repr__</code> gives you a readable printout for free. Call <code>print(API_CONFIG)</code>: it shows every field and value without a single line of formatting code. It's handy for quick sanity checks when you're debugging a pipeline run.</p>
<pre><code class="language-python">print(APIConfig)
# prints APIConfig(use_mock=True, base_url='https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab', ...)
</code></pre>
<h2 id="heading-part-4-the-extraction-step">Part 4: The Extraction Step</h2>
<p>The Extract phase pulls data from source systems and reads it into memory. Here, you genuinely have <strong>two</strong> sources to extract from: the <em>existing</em> dataset (what you already published last time) and the <em>new</em> (recent) data from the Hub'Eau API.</p>
<h3 id="heading-graceful-file-loading">Graceful File Loading</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>What should happen the very first time this pipeline runs (and there's no existing dataset yet)? A naïve implementation would crash with a <code>FileNotFoundError</code>.</p>
<p>Here's the trick: <code>load_csv()</code> follows the <a href="https://en.wikipedia.org/wiki/Null_object_pattern"><strong>Null Object pattern</strong></a>. Instead of raising an error, it returns an <em>empty</em> DataFrame. Every downstream function can then treat "no existing data" and "some existing data" the same way, with no special-casing needed.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p>This function loads a CSV file into a DataFrame. If the file doesn't exist, it returns an empty DataFrame instead of crashing.</p>
<p><code>low_memory=False</code> tells pandas to read the file carefully, so it avoids mixed-type guesses. <code>parse_dates=True</code> tries to automatically convert date-like columns into dates. <code>delimiter=","</code> tells pandas the file is comma-separated.</p>
<pre><code class="language-python">def load_csv(path: str) -&gt; pd.DataFrame:
    """Load CSV file or return empty DataFrame if file does not exist.

    Args:
        path (str): Full path to the CSV file.

    Returns:
        pd.DataFrame: Loaded data, or empty DataFrame if file not found.

    Raises:
        pd.errors.ParserError: If the CSV is malformed.
    """
    if os.path.exists(path):
        return pd.read_csv(path, low_memory=False, parse_dates=True, delimiter=",")
    return pd.DataFrame()   # Null Object: consistent return type
</code></pre>
<p>To test, try it against a path that doesn't exist:</p>
<pre><code class="language-python">df_missing = load_csv("/tmp/does_not_exist.csv")
print(df_missing.empty)  # True: no crash

# Callers can always do this, instead of an `is None` check:
if df_missing.empty:
    print("No existing data. Will run a full fetch from earliest date.")
</code></pre>
<p><strong>Best practice:</strong> return a <strong>consistent type</strong> from every code path in a function. A function that sometimes returns a <code>DataFrame</code> and sometimes <code>None</code> forces every caller to add a <code>None</code> check before using the result.</p>
<p>Return a <code>DataFrame</code>, empty or not, which keeps things simpler. You never have to ask <em>"did I get a real result, or</em> <code>None</code><em>?"</em> before using it.</p>
<h3 id="heading-incremental-update-logic">Incremental Update Logic</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>This is the "incremental loading" pattern from Part 1 in action. Before you fetch anything, ask: <em>"What's the most recent record I already have, and do I actually need more?"</em></p>
<p><strong>Strategy:</strong> check whether the existing data already covers <em>yesterday</em>.</p>
<ul>
<li><p>Yes: skip the update entirely. Nothing to do, the dataset is current.</p>
</li>
<li><p>No: fetch starting from the day after the last known date.</p>
</li>
</ul>
<pre><code class="language-text">Existing data: Jan. 1 – Jan. 15
Yesterday: Jan. 19

Decision: fetch from Jan. 16 onwards (not from Jan. 1)
</code></pre>
<p>Why <em>yesterday</em> and not <em>today</em>? Today's measurement might not be finalized on the source system yet. Hub'Eau's "elaborated observations" are a processed daily aggregate, so the safest check is against the last <strong>fully completed</strong> day.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>determine_update_range()</code> checks the newest saved date. It tells you either "you're already up to date" or "start downloading from this next date."</p>
<p>Step by step:</p>
<ol>
<li><p><strong>The function starts with existing data</strong></p>
<ul>
<li><code>existing</code> is a pandas <code>DataFrame</code> that already has some rows of data.</li>
</ul>
</li>
<li><p><strong>If there is no data at all</strong></p>
<ul>
<li><p><code>if existing.empty:</code></p>
</li>
<li><p>If the DataFrame has zero rows, it says: <em>"Nothing is saved yet, so fetch everything."</em></p>
</li>
<li><p>It returns:</p>
<ul>
<li><p><code>True</code> = update needed</p>
</li>
<li><p><code>STATION_CONFIG.earliest_date</code> = start from the earliest allowed date</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>Find the date column</strong></p>
<ul>
<li><p>The code checks which column contains dates:</p>
<ul>
<li><p>first tries <code>"date_obs_elab"</code></p>
</li>
<li><p>then <code>"record_date"</code></p>
</li>
</ul>
</li>
<li><p>If neither exists, it raises an error because it doesn’t know which column to use.</p>
</li>
</ul>
</li>
<li><p><strong>Convert the date column into real dates</strong></p>
<ul>
<li><p><code>pd.to_datetime(...)</code> turns the column into date objects pandas can work with.</p>
</li>
<li><p><code>errors="coerce"</code> means bad date values become missing values instead of crashing.</p>
</li>
</ul>
</li>
<li><p><strong>Find the latest date in the data</strong></p>
<ul>
<li><p><code>last_day = s.max().date()</code></p>
</li>
<li><p>This gets the newest date already in the dataset.</p>
</li>
</ul>
</li>
<li><p><strong>Compare it with yesterday</strong></p>
<ul>
<li><p><code>yesterday = date.today() - timedelta(days=1)</code></p>
</li>
<li><p>The function checks whether data already includes yesterday.</p>
</li>
</ul>
</li>
<li><p><strong>If data is already up to date</strong></p>
<ul>
<li><p>If <code>last_day &gt;= yesterday</code>, it returns:</p>
<ul>
<li><p><code>False</code> = no update needed</p>
</li>
<li><p><code>None</code> = no start date needed</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>If data is behind</strong></p>
<ul>
<li><p>It sets <code>next_day</code> to the day after the last saved date.</p>
</li>
<li><p>Then it returns:</p>
<ul>
<li><p><code>True</code> = update needed</p>
</li>
<li><p>that next day as a string like <code>"2026-07-12"</code></p>
</li>
</ul>
</li>
</ul>
</li>
</ol>
<pre><code class="language-python">def determine_update_range(existing: pd.DataFrame) -&gt; Tuple[bool, Optional[str]]:
    """Determine whether an update is needed and from what date.

    Logic:
    1. Check if existing data covers yesterday's date
    2. If yes → no update needed
    3. If no → start fetching from day after last data

    Returns:
        Tuple[should_update, start_date]
    """
    # Case 1: nothing on disk yet
    if existing.empty:
        print("No existing data found. Will fetch all data from earliest date.")
        return True, STATION_CONFIG.earliest_date

    if "date_obs_elab" in existing.columns:
        record_colname = "date_obs_elab"
    elif "record_date" in existing.columns:
        record_colname = "record_date"
    else:
        raise KeyError("Missing date column: expected 'date_obs_elab' or 'record_date'")

    s = pd.to_datetime(existing[record_colname], errors="coerce")
    last_day = s.max().date()
    yesterday = date.today() - timedelta(days=1)

    # Case 2: already current
    if last_day &gt;= yesterday:
        print("\nDataset already covers yesterday or later. No update needed.")
        return False, None

    # Case 3: fetch the gap
    next_day = (last_day + pd.Timedelta(days=1))
    print(f"\nWill retrieve data starting from: {next_day}")
    return True, next_day.isoformat()
</code></pre>
<p>A few things worth a note here.</p>
<p>First, the function checks for <strong>two possible column names</strong>: <code>date_obs_elab</code>, the raw API name, or the already-renamed English name <code>record_date</code>. It doesn't assume just one. This makes the function work whether you call it on freshly-fetched raw data or an already-processed CSV loaded from disk.</p>
<p><code>errors="coerce"</code> shows up here, and it'll show up again (we'll dig into this in Part 5). Any date pandas can't parse becomes <code>NaT</code> (Not a Time) instead of raising an exception.</p>
<p>The return type is <code>Tuple[bool, Optional[str]]</code>. This <a href="https://www.w3schools.com/python/python_tuples.asp">tuple</a> bundles two related results: <em>should I update, and from when?</em> That beats returning two separate values, or worse, one ambiguous value that means different things depending on context.</p>
<p><strong>Best practice:</strong> use <code>Tuple</code> return types (or, for more fields, a small dataclass or <code>NamedTuple</code>) to bundle related results together. Document clearly what each position means. If you return different <em>types</em> from different code paths without documentation, you'll hit a common source of confusion and bugs: <em>"why is this</em> <code>None</code> <em>sometimes and a string other times?"</em>.</p>
<p>Run a quick check against three scenarios:</p>
<pre><code class="language-python"># Test 1: No existing data
should_update, start_date = determine_update_range(pd.DataFrame())
# → True, "1900-01-01"

# Test 2: Old existing data (covers only Jan 10-15)
# → True, "2026-01-16"  (the day after the last known date)

# Test 3: Recent data that already covers yesterday
# → False, None
</code></pre>
<h3 id="heading-simulate-the-api">Simulate the API</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>In production, fetching means a real HTTP call:</p>
<pre><code class="language-python">requests.get(
    "https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab",
    params={"code_entite": "F700000109", "size": 20000, ...}
)
</code></pre>
<p>Real API calls bring real challenges. Rather than fight those on your first read-through, this tutorial first builds and tests everything against a <strong>mock</strong> generator. It returns data shaped exactly like the real API. Only once the logic works does it swap in the real endpoint.</p>
<p>This technique is useful well beyond this project. Build and test your transform logic against fixtures or mocks first. That way, you're not into "is my parsing wrong?" and "is the network flaky right now?" at the same time.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>generate_mock_api_data()</code> generates fake sample data for a station, one record per day, starting from <code>start_date</code>.</p>
<p>How it works:</p>
<ul>
<li><p>It converts <code>start_date</code> into a real date.</p>
</li>
<li><p>It loops for <code>num_days</code>.</p>
</li>
<li><p>For each day, it creates a mock observation dictionary.</p>
</li>
<li><p>It adds a small random variation to the water level so the data looks realistic.</p>
</li>
<li><p>It randomly picks status, quality, and method labels.</p>
</li>
<li><p>It returns a list of these dictionaries.</p>
</li>
</ul>
<pre><code class="language-python">def generate_mock_api_data(station_code: str, start_date: str, num_days: int = 10) -&gt; List[Dict]:
    """Generate realistic mock API data for demonstration.

    Simulates what HubEau API would return: list of observation dicts.
    """
    start = pd.to_datetime(start_date).date()
    records = []

    validation_statuses = ["Donnée validée", "Donnée brute", "Donnée pré-validée"]
    qualities = ["Bonne", "Non qualifiée", "Douteuse"]
    methods = ["Mesurée", "Calculée", "Expertisée"]

    for i in range(num_days):
        obs_date = start + timedelta(days=i)
        base_level = 5500 + int(station_code[-2:])  # Varies by station
        noise = random.randint(-200, 200)
        water_level = base_level + noise

        record = {
            "code_site": "mock_" + station_code[1:],
            "code_station": "mock_" + station_code,
            "date_obs_elab": obs_date.isoformat(),
            "resultat_obs_elab": water_level,
            "date_prod": (obs_date + timedelta(days=1)).isoformat(),
            "code_statut": "1",
            "libelle_statut": random.choice(validation_statuses),
            "code_methode": "1",
            "libelle_methode": random.choice(methods),
            "code_qualification": "1",
            "libelle_qualification": random.choice(qualities),
            "longitude": 2.3522 + random.uniform(-0.01, 0.01),
            "latitude": 48.8566 + random.uniform(-0.01, 0.01),
            "grandeur_hydro_elab": "mock_HIXnJ",
        }
        records.append(record)

    return records
</code></pre>
<h3 id="heading-fetch-data-for-real">Fetch Data for Real</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Two functions handle extraction. They're deliberately split according to the <a href="https://en.wikipedia.org/wiki/Single-responsibility_principle"><strong>Single Responsibility Principle</strong></a> (SRP): each function should have one reason to change.</p>
<pre><code class="language-text">fetch_all_data() (orchestrator)
    ├── fetch_single_station_data(station_1) ← handles all complexity
    ├── fetch_single_station_data(station_2) ← handles all complexity
    └── fetch_single_station_data(station_n) ← handles all complexity
</code></pre>
<ul>
<li><p><code>fetch_single_station_data()</code> owns <em>all</em> the messy per-station complexity: pagination, cursor advancement, stop conditions, and network error handling.</p>
</li>
<li><p><code>fetch_all_data()</code> owns none of that. It just loops over stations and delegates.</p>
</li>
</ul>
<p>This split has two payoffs. First, you can debug or swap out the pagination strategy for one station without touching the orchestration code at all. Second, if you ever want to parallelize fetching (with <code>concurrent.futures</code> or <code>asyncio</code>, for example), the orchestrator is the <em>only</em> place you'd need to touch.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>fetch_single_station_data()</code> fetches station data either from mock test data or from the real API, page by page, until it has everything it needs.</p>
<ul>
<li><p><strong>If</strong> <code>use_mock=True</code>, it uses <strong>fake data</strong> instead of calling the real API.</p>
<ol>
<li><p>It calls <code>generate_mock_api_data()</code></p>
</li>
<li><p>Turns the result into a DataFrame</p>
</li>
<li><p>Converts the date column into real pandas dates</p>
</li>
<li><p>Returns that DataFrame</p>
</li>
</ol>
</li>
<li><p><strong>If</strong> <code>use_mock=False</code>, it does the <strong>real API request</strong>:</p>
<ol>
<li><p>Creates a reusable HTTP session</p>
</li>
<li><p>Starts from <code>start_date</code></p>
</li>
<li><p>Repeatedly asks the API for a page of data</p>
</li>
<li><p>Stops when:</p>
<ul>
<li><p>the API returns no data</p>
</li>
<li><p>the latest date reaches yesterday</p>
</li>
<li><p>the page is smaller than expected</p>
</li>
<li><p>a network error happens</p>
</li>
</ul>
</li>
<li><p>Combines all pages into one DataFrame</p>
</li>
<li><p>Returns an empty DataFrame if nothing was fetched</p>
</li>
</ol>
</li>
</ul>
<pre><code class="language-python">def fetch_single_station_data(station_code: str, start_date: str, use_mock: bool = True) -&gt; pd.DataFrame:
    """Fetch all hydrometric data for a single station from mock API (default) or from the real endpoint.

    In production (cursor based pagination strategy):
    - Fetches max_per_page records per request
    - Continues until no new data or yesterday's date reached
    - Stop when: no data returned | last date &gt;= yesterday | page was not full
    - Handles network errors gracefully
    """
    if use_mock:
        data = generate_mock_api_data(station_code, start_date, num_days=7)
        page_df = pd.DataFrame(data)
        page_df["date_obs_elab"] = pd.to_datetime(
            page_df["date_obs_elab"], errors="coerce").dt.normalize()
        return page_df

    # Real implementation
    else:
        session = requests.Session()  # Reuse TCP connection across pages
        frames = []
        cursor = start_date

        while True:
            params = {
                "code_entite": station_code,
                "grandeur_hydro_elab": API_CONFIG.metric,
                "date_debut_obs_elab": cursor,
                "size": API_CONFIG.max_per_page,
            }

            try:
                response = session.get(
                    API_CONFIG.base_url,
                    params=params,
                    timeout=API_CONFIG.timeout_seconds  # Best practice, always set
                )
                response.raise_for_status()
            except requests.RequestException as e:  # Don't let one station kill the whole pipeline
                print(f"Error fetching data for station {station_code}: {e}")
                break

            data = response.json().get("data", [])
            if not data:  # Empty response: we've exhausted this station
                break

            page_df = pd.DataFrame(data)
            page_df["date_obs_elab"] = pd.to_datetime(
                page_df["date_obs_elab"], errors="coerce").dt.normalize()
            frames.append(page_df)

            last_page_date = page_df["date_obs_elab"].max()
            yesterday = date.today() - timedelta(days=1)

            # Prevent infinite loops
            if pd.isna(last_page_date) or last_page_date.date() &gt;= yesterday:
                break

            cursor = (last_page_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")

            if len(data) &lt; API_CONFIG.max_per_page:
                break

    if frames:
        return pd.concat(frames, ignore_index=True)
    return pd.DataFrame()
</code></pre>
<p><strong>How does the pagination loop actually work?</strong></p>
<p>Let's walk through it step by step. This is the densest bit of logic in the whole notebook.</p>
<ol>
<li><p>Send a request with <code>date_debut_obs_elab=cursor</code>: "give me records from this date on."</p>
</li>
<li><p>If the request fails outright (<code>requests.RequestException</code>), log it and <code>break</code>. <code>break</code> stops the loop right away and moves on. One station's network hiccup shouldn't kill the pipeline for every other station.</p>
</li>
<li><p>If the response has no data at all, you've caught up: <code>break</code>.</p>
</li>
<li><p>Otherwise, note the <em>latest</em> date seen on this page (<code>last_page_date</code>).</p>
</li>
<li><p>If that latest date is already <code>&gt;= yesterday</code>, you've caught up: <code>break</code>.</p>
</li>
<li><p>Otherwise, advance the cursor to <code>last_page_date + 1 day</code> and loop again for the next page.</p>
</li>
<li><p>As a safety net: if the page returned <em>fewer</em> records than <code>max_per_page</code>, that also means you've reached the end. The API wouldn't return a partial page unless it ran out of data, so: <code>break</code>.</p>
</li>
</ol>
<p>That last check (step 7) is a classic <strong>pagination termination heuristic</strong>. You don't always need a <code>next_page</code> token from the API. If a full page is <code>size=20000</code> and you get back only <code>4213</code> records, there's nothing left to fetch.</p>
<p><strong>Three specific, deliberate choices, called out:</strong></p>
<pre><code class="language-python">session = requests.Session()
</code></pre>
<p>A <a href="https://requests.readthedocs.io/en/latest/user/advanced/#session-objects"><code>Session</code></a> object reuses the underlying TCP connection across multiple requests to the same host. That avoids a fresh TCP/TLS handshake on every single page request. In short, it's faster for you and more polite to Hub'Eau's servers.</p>
<p><strong>Best practice:</strong> any time you call <code>requests.get()</code> more than once against the same host in a loop, reach for a <code>Session</code>.</p>
<pre><code class="language-python">except requests.RequestException as e:
</code></pre>
<p><code>RequestException</code> is the base class for <a href="https://requests.readthedocs.io/en/latest/api/#requests.RequestException">every exception</a> <code>requests</code> can raise: timeouts, connection errors, HTTP errors from <code>raise_for_status()</code> and more. Catching the base class here means <em>any</em> network hiccup gets handled the same forgiving way: log it, stop fetching this station, move on.</p>
<pre><code class="language-python">timeout=API_CONFIG.timeout_seconds
</code></pre>
<p>By default, <code>requests</code> calls <strong>never time out</strong>. Without an explicit timeout, a hung server can freeze your entire pipeline indefinitely. The <a href="https://requests.readthedocs.io/en/latest/user/advanced/#timeouts">requests advanced usage docs</a> states that requests to external servers should have a timeout attached.</p>
<p><strong>Best practice:</strong> wrap every external I/O call in <code>try/except</code>. Always fail gracefully. Log the error and let the pipeline recover or move on. This avoids one flaky request taking down an unattended weekly job.</p>
<p>Now the orchestrator, <code>fetch_all_data()</code>, is deliberately much simpler:</p>
<ol>
<li><p>Loop through all station codes.</p>
<ul>
<li><p>Fetch each station's data.</p>
</li>
<li><p>Keep the non-empty results.</p>
</li>
</ul>
</li>
<li><p>Combine them into one big DataFrame.</p>
</li>
</ol>
<pre><code class="language-python">def fetch_all_data(start_date: str, use_mock: bool = True) -&gt; pd.DataFrame:
    """Orchestrator: Fetch data for all configured stations."""
    frames = []

    for station_code in STATION_CONFIG.station_codes:
        print(f"Fetching data for station {station_code}...")
        df_station = fetch_single_station_data(station_code, start_date, use_mock=use_mock)

        if not df_station.empty:
            print(f"  Got {len(df_station)} records")
            frames.append(df_station)
        else:
            print(f"  (no data)")

    if frames:
        return pd.concat(frames, ignore_index=True)
    return pd.DataFrame()
</code></pre>
<p>That's it. A loop and a <code>pd.concat</code>. All the hard-won complexity lives one layer down, exactly where SRP says it should.</p>
<h2 id="heading-part-5-the-transform-step">Part 5: The Transform Step</h2>
<p>This is where raw, freshly-fetched data becomes something you can publish. Here's the design philosophy for this whole section: compose many small, <strong>pure functions</strong>. Each one takes a DataFrame in and returns a <em>new</em> DataFrame out without side effects. Avoid the one giant do-everything function trap.</p>
<pre><code class="language-text">    (EXTRACT)
    Raw API Data
    ↓
    (TRANSFORM)
1. Type parsing (datetime, numeric)
2. Column renaming (French → English)
3. Categorical mapping (validation status, quality)
4. Derived columns computation (flood alert flags)
5. Column reordering (logical grouping)
6. Sorting &amp; index reset
    ↓
    (LOAD)
    Publication-ready dataset
</code></pre>
<p>Why split this into six tiny steps instead of one big function? Each piece is testable and replaceable on its own. When something breaks at 3am on a scheduled run, every function is debuggable in isolation. You can pinpoint exactly which stage produced bad output. No need to pick apart one 200-line function.</p>
<h3 id="heading-type-parsing-and-graceful-coercion">Type Parsing and Graceful Coercion</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Data from a CSV or a JSON API starts out, by default, as strings. Pandas needs real types to sort dates chronologically, do date arithmetic (<code>last_date + timedelta(days=1)</code>), or compare numeric values (<code>water_level &gt; 6000</code>). Type mismatches are a common error that break batch pipelines. One malformed row, like <code>"N/A"</code>, a truncated date, misaligned values, or stray characters and a strict parser throws an exception that kills the whole run.</p>
<p><strong>Best practice:</strong> both conversion functions below use <code>errors="coerce"</code>, so values pandas can't parse become <code>NaT</code> (Not a Time) or <code>NaN</code> (Not a Number) instead of raising an error. This is called <a href="https://stackoverflow.com/questions/36394814/what-is-the-significance-of-coerce-in-python-pandas">graceful coercion</a>.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>convert_to_date()</code> and <code>convert_to_numeric()</code> are small helper functions that make sure certain columns have the right type.</p>
<ul>
<li><p>Both start with <code>df.copy()</code> so they don’t change the original DataFrame.</p>
</li>
<li><p><code>errors="coerce"</code> means bad values become missing values instead of causing a crash.</p>
</li>
</ul>
<p>For <code>convert_to_date()</code>:</p>
<ol>
<li><p><code>df.copy()</code> makes a separate copy, so the original table stays unchanged</p>
</li>
<li><p>The loop goes through each column name in <code>columns</code></p>
<ul>
<li><p><code>if col in df.columns</code> checks that the column actually exists before trying to convert it</p>
</li>
<li><p><code>pd.to_datetime(...)</code> turns text like <code>"2026-07-12"</code> into real pandas date/time values</p>
</li>
<li><p><code>errors="coerce"</code> means invalid values become <code>NaT</code> (missing date) instead of raising an error</p>
</li>
<li><p><code>.dt.normalize()</code> removes the time part and keeps only the date at midnight</p>
</li>
</ul>
</li>
</ol>
<p>For <code>convert_to_numeric()</code>:</p>
<ol>
<li><p><code>df.copy()</code> is used here as well.</p>
</li>
<li><p>The loop goes through each column name in <code>columns</code></p>
<ul>
<li><p><code>if col in df.columns</code> checks that the column actually exists before trying to convert it</p>
</li>
<li><p>The loop calls <code>pd.to_numeric()</code> turns text like <code>"12.5"</code> into numbers</p>
</li>
<li><p><code>errors="coerce"</code> turns bad values into <code>NaN</code> instead of crashing</p>
</li>
</ul>
</li>
</ol>
<pre><code class="language-python">def convert_to_date(df: pd.DataFrame, columns: List[str]) -&gt; pd.DataFrame:
    """Convert specified columns to pandas datetime type."""
    df = df.copy()  # Never modify the original!
    for col in columns:
        if col in df.columns:
            df[col] = pd.to_datetime(df[col], errors="coerce").dt.normalize()
    return df


def convert_to_numeric(df: pd.DataFrame, columns: List[str]) -&gt; pd.DataFrame:
    """Convert specified columns to numeric (float) type."""
    df = df.copy()
    for col in columns:
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors="coerce")
    return df
</code></pre>
<p>Why is this useful?</p>
<ul>
<li><p>You can aggregate the data: group it, summarize it and so on...</p>
</li>
<li><p>Dates become sortable and filterable as real dates.</p>
</li>
<li><p>Numbers work correctly in calculations like averages, sums, or comparisons.</p>
</li>
<li><p>It prevents bugs caused by mixed types, like <code>"12"</code> and <code>12</code>.</p>
</li>
</ul>
<p>Both <code>pd.to_datetime</code> and <code>pd.to_numeric</code> are official pandas functions with an <code>errors</code> parameter. By default, that parameter is set to <code>"raise"</code>, which throws on bad input. The other options are <code>"coerce"</code> (replace with null) or <code>"ignore"</code> (leave untouched). See the <a href="https://pandas.pydata.org/docs/reference/api/pandas.to_datetime.html">pandas <code>to_datetime</code> docs</a> and <a href="https://pandas.pydata.org/docs/reference/api/pandas.to_numeric.html"><code>to_numeric</code> docs</a> for the full parameter list.</p>
<p>Test it against an example of messy input:</p>
<pre><code class="language-python">messy_df = pd.DataFrame({
    "date_obs_elab": ["2026-01-15", "2026-01-16", "not a date", None],
    "resultat_obs_elab": [5800.0, "5900", "N/A", None],
})

type_safe_df = convert_to_date(messy_df, ["date_obs_elab"])
type_safe_df = convert_to_numeric(type_safe_df, ["resultat_obs_elab"])

# "not a date"  → NaT
# "N/A"         → NaN
# Pipeline continues safely — nothing crashed.
</code></pre>
<p><strong>Best practice:</strong> don't let one bad row kill an entire run. Coerce bad data to nulls rather than raising exceptions. Flag or log nulls separately if you need to investigate data quality later.</p>
<p>This is a deliberate trade-off. A choice between <em>availability</em> (the pipeline keeps running) over <em>strictness</em> (catching every bad row immediately). That's usually the right call for a scheduled, unattended job.</p>
<h4 id="heading-dfcopy-immutability-by-convention"><code>df.copy()</code>: immutability by convention</h4>
<p>Look again at the top of both functions above: <code>df = df.copy()</code>. This single line appears at the start of <strong>every transform function</strong> in the pipeline, and that's not an accident.</p>
<p>Python DataFrames are mutable objects, passed by reference. If a function modifies <code>df</code> in place without copying first, the caller's original DataFrame changes too. That's a classic <a href="https://en.wikipedia.org/wiki/Side_effect_(computer_science)">side effect</a> and it can produce genuinely confusing bugs. Call <code>.copy()</code> first means each function's output is a brand-new object. The input the caller passed in stays <em>guaranteed untouched</em>.</p>
<p><strong>Best practice:</strong> treat DataFrames as <strong>immutable inputs</strong>. Return a new DataFrame rather than modify it one in place. Even if it costs a small amount of memory or CPU, the debugging win is almost always worth it for a pipeline that isn't operating at extreme scale.</p>
<p>Finally, one auto-detecting convenience function wraps these two low-level functions. It scans text columns, guesses whether they contain dates or numbers, and calls the matching parsing function above.</p>
<p><code>auto_convert_columns()</code> tries to <strong>guess which columns are dates or numbers</strong> and then fixes these types automatically.</p>
<p>How it works:</p>
<p>It starts with two empty lists:</p>
<ul>
<li><p><code>datetime_cols</code> for date columns</p>
</li>
<li><p><code>numeric_cols</code> for number columns</p>
</li>
</ul>
<p>It goes through each column in the DataFrame. If a column is already a real datetime or numeric type, it skips it. If the column is text-like (<code>object</code> or <code>string</code>), it looks at up to 10 non-empty sample values.</p>
<p>It first tries to read those values as dates:</p>
<ul>
<li>if that works, the column is added to <code>datetime_cols</code></li>
</ul>
<p>If not, it tries to read them as numbers:</p>
<ul>
<li>if that works, the column is added to <code>numeric_cols</code></li>
</ul>
<p>At the end, it converts all date columns with <code>convert_to_date()</code>. Then it converts all numeric columns with <code>convert_to_numeric()</code></p>
<pre><code class="language-python">def auto_convert_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Auto-detect and convert datetime and numeric columns to the correct type."""
    datetime_cols = []
    numeric_cols = []

    for col in df.columns:
        if pd.api.types.is_datetime64_any_dtype(df[col]):
            continue
        if pd.api.types.is_numeric_dtype(df[col]):
            continue

        if pd.api.types.is_object_dtype(df[col]) or pd.api.types.is_string_dtype(df[col]):
            sample = df[col].dropna().head(10)
            if len(sample) == 0:
                continue

            try:
                pd.to_datetime(sample, errors='raise', format='mixed')
                datetime_cols.append(col)
                continue
            except (ValueError, TypeError):
                pass

            try:
                pd.to_numeric(sample, errors='raise')
                numeric_cols.append(col)
                continue
            except (ValueError, TypeError):
                pass

    df = convert_to_date(df, datetime_cols)
    df = convert_to_numeric(df, numeric_cols)
    return df
</code></pre>
<p>Notice the inner <code>try/except</code> blocks here use <code>errors='raise'</code>. That's the <em>opposite</em> of the coercion strategy above, but it only runs against a small <code>.head(10)</code> <strong>sample</strong> of each column.</p>
<p>This is a type-<em>sniffing</em> step, not the final conversion. It tests <em>"does this column look like dates, or numbers, or neither?"</em> on a cheap sample. Then, it hands off the actual, forgiving conversion of the <em>whole</em> column to <code>convert_to_date()</code> or <code>convert_to_numeric()</code>. Two different <code>errors</code> strategies, two different jobs.</p>
<h3 id="heading-schema-translation-with-bidirectional-mappings">Schema Translation with Bidirectional mappings</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>The Hub'Eau API returns French column names and French categorical values, like <code>code_station</code> or <code>"Donnée validée"</code>. A dataset meant for an international audience should ship in English. If you rename columns inline, wherever it's convenient, the mapping between French and English ends up scattered across the codebase. Then you'd have no way to reverse it if you ever needed to.</p>
<p><strong>The fix:</strong> define <strong>one authoritative mapping</strong> at the top of the module, and derive everything else from it.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>API_TO_EN</code> maps API field names to English translations.</p>
<pre><code class="language-python"># Primary mapping: French API columns to English column names
API_TO_EN = {
    "code_site": "location_code",
    "code_station": "station_code",
    "date_obs_elab": "record_date",
    "resultat_obs_elab": "water_level_mm",
    "date_prod": "data_production_date",
    "code_statut": "validation_status_code",
    "libelle_statut": "validation_status",
    "code_methode": "production_method_code",
    "libelle_methode": "production_method",
    "code_qualification": "quality_code",
    "libelle_qualification": "quality_assessment",
    "longitude": "longitude",
    "latitude": "latitude",
    "grandeur_hydro_elab": "hubeau_elab_code",
}

# Reverse mapping: English to French (computed automatically)
EN_TO_API = {v: k for k, v in API_TO_EN.items()}
</code></pre>
<p>The second line (<code>EN_TO_API</code>) is just a shortcut: it swaps each key and value from <code>API_TO_EN</code>. A <a href="https://docs.python.org/3/tutorial/datastructures.html#dictionaries">dict comprehension</a> builds it. <strong>Here's the important design point:</strong> <code>EN_TO_API</code> isn't hand-maintained, it's <em>derived</em>. If you add, remove, or rename an entry in <code>API_TO_EN</code>, <code>EN_TO_API</code> updates automatically the next time the module runs.</p>
<p>There's <em>exactly one place</em> in the entire codebase where a schema change needs to happen.</p>
<p>Categorical <em>values</em> (not just column names) get the same treatment:</p>
<pre><code class="language-python">CATEGORICAL_MAPPINGS = {
    "validation_status": {
        "Donnée validée": "validated",
        "Donnée brute": "raw",
        "Donnée pré-validée": "pre-validated",
    },
    "quality_assessment": {
        "Bonne": "good",
        "Non qualifiée": "unqualified",
        "Douteuse": "dubious",
    },
    "production_method": {
        "Calculée": "calculated",
        "Mesurée": "measured",
        "Expertisée": "expert-reviewed",
    },
}
</code></pre>
<p>The functions below apply these mappings, in order, to standardize column names and category values.</p>
<p><code>rename_to_english()</code>:</p>
<ol>
<li><p>If the DataFrame is empty, it returns a copy right away.</p>
</li>
<li><p>It builds a list of columns that are renamed from API names to English names.</p>
<ul>
<li><p>It only renames a column if:</p>
<ul>
<li><p>the old name exists, and</p>
</li>
<li><p>the new name does not already exist</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Then it returns the renamed DataFrame.</p>
</li>
</ol>
<p><code>rename_to_api_schema()</code>:</p>
<ol>
<li><p>Also returns a copy for empty data.</p>
</li>
<li><p>Does the reverse: English names back to API names.</p>
</li>
<li><p>Returns the DataFrame.</p>
</li>
</ol>
<p>(Useful if you need to send data back in the API’s original format).</p>
<p><code>apply_categorical_mappings()</code>:</p>
<ol>
<li><p>Makes a copy so the original DataFrame is not changed.</p>
</li>
<li><p>For each column in <code>CATEGORICAL_MAPPINGS</code>, it replaces values using the mapping.</p>
<ul>
<li><p>Example: <code>"Donnée validée"</code> becomes <code>"validated"</code>.</p>
</li>
<li><p><code>.fillna(df[col_name])</code> keeps the original value if a value isn’t found in the mapping.</p>
</li>
</ul>
</li>
<li><p>Returns the DataFrame.</p>
</li>
</ol>
<pre><code class="language-python">def rename_to_english(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Rename API column names to English schema names."""
    if df.empty:
        return df.copy()

    columns_to_rename = {}
    for src, dst in API_TO_EN.items():
        if src in df.columns and dst not in df.columns:
            columns_to_rename[src] = dst

    return df.rename(columns=columns_to_rename)

def rename_to_api_schema(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Rename English column names back to API schema names (defensive/reverse operation)."""
    if df.empty:
        return df.copy()

    columns_to_rename = {k: v for k, v in EN_TO_API.items() if k in df.columns}
    return df.rename(columns=columns_to_rename)
</code></pre>
<pre><code class="language-python">def apply_categorical_mappings(df: pd.DataFrame) -&gt; pd.DataFrame:
    df = df.copy()
    for col_name, mapping in CATEGORICAL_MAPPINGS.items():
        if col_name in df.columns:
            df[col_name] = df[col_name].map(mapping).fillna(df[col_name])
    return df
</code></pre>
<p>Two defensive habits are worth a note here:</p>
<p><strong>Robustness to partial inputs:</strong> Both rename functions only rename columns that actually exist in the input (<code>if src in df.columns</code>).</p>
<p>A rename function that assumes every mapped column is always present will crash the moment it's called on a partial or differently-shaped DataFrame.</p>
<p><code>.map(mapping).fillna(df[col_name])</code>. <a href="https://pandas.pydata.org/docs/reference/api/pandas.Series.map.html"><code>Series.map</code></a> replaces every value it finds in the mapping dict and turns any value <em>not</em> found in the dict into <code>NaN</code>. Chain <code>.fillna(df[col_name])</code> right after restores the <em>original</em> value wherever the mapping didn't apply. That way, unexpected categorical values pass through unchanged instead of silently becoming null. It's a subtle but important robustness choice.</p>
<p>See this exact <code>.map()</code>-then-<code>.fillna()</code> idiom discussed on <a href="https://stackoverflow.com/questions/19798153/difference-between-map-applymap-and-apply-methods-in-pandas">Stack Overflow: pandas map vs apply performance</a>.</p>
<p>A quick round-trip test proves the bidirectional mapping actually works:</p>
<pre><code class="language-python">sample_api_df = pd.DataFrame({"code_station": ["F700000109"], ...})
renamed_df = rename_to_english(sample_api_df)          # French → English
reversed_df = rename_to_api_schema(renamed_df)          # English → French
# reversed_df.columns.tolist() == sample_api_df.columns.tolist()  → True
</code></pre>
<h3 id="heading-compute-flood-alerts">Compute Flood Alerts</h3>
<p><code>add_derived_columns()</code> is a one-liner. It just computes <code>flood_alert</code>. If the water level is greater than the flood threshold set in the <code>Config</code>, the result is <code>True</code>. Otherwise, the result is <code>False</code>.</p>
<pre><code class="language-python">def add_derived_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Add a computed columns based on raw data.
    """
    df = df.copy()

    if "water_level_mm" in df.columns:
        df["flood_alert"] = df["water_level_mm"] &gt; STATION_CONFIG.flood_threshold_mm

    return df
</code></pre>
<h3 id="heading-column-ordering">Column Ordering</h3>
<p>Here's a small but user-facing detail: define a preferred column order once, as data, and reuse it everywhere. <code>COLUMN_ORDER</code> holds that preferred column sequence.</p>
<p><code>order_columns()</code> rearranges a DataFrame so those columns come first, while any extra columns stay at the end.</p>
<pre><code class="language-python">COLUMN_ORDER = [
    # Primary identifiers &amp; measurements
    "station_code", "record_date", "water_level_mm", "flood_alert",
    # Metadata about the observation
    "hubeau_elab_code", "data_production_date",
    "validation_status_code", "validation_status",
    "production_method_code", "production_method",
    "quality_code", "quality_assessment",
    # Geographic info (less important)
    "location_code", "longitude", "latitude",
]

def order_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Reorder columns to preferred order."""
    present_cols = [c for c in COLUMN_ORDER if c in df.columns]
    other_cols = [c for c in df.columns if c not in present_cols]
    return df[present_cols + other_cols]
</code></pre>
<p><code>other_cols</code> acts as a safety net: any column not explicitly listed in <code>COLUMN_ORDER</code> still gets included at the end. They're not silently dropped.</p>
<p>Rules, in order of priority:</p>
<ul>
<li><p>Keep identifiers and key fields up front.</p>
</li>
<li><p>Put the most important, most frequently used, and most stable columns first.</p>
</li>
<li><p>Group related fields together, so the table reads naturally.</p>
</li>
<li><p>Push optional or rarely-used fields to the end.</p>
</li>
</ul>
<h3 id="heading-deduplication">Deduplication</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Remove duplicates as you go. In an incremental pipeline, date ranges and records can overlap:</p>
<ul>
<li><p>The same day might get re-fetched, because API data arrives late or gets finalized later.</p>
</li>
<li><p>The same observation might appear on two different pages of a paginated response.</p>
</li>
</ul>
<p>Without deduplication, these scenarios let duplicate rows pile up in the dataset over time. This is also, recall from Part 1, exactly what makes the pipeline <strong>idempotent</strong>: run it once or run it five times and the resulting dataset stays identical.</p>
<p>The fix requires defining what makes a record <strong>unique</strong>. Define a composite key:</p>
<pre><code class="language-text">key = (station_code, observation_date, water_level_value)
</code></pre>
<p>Why this specific combination? Physically, one sensor (<code>station_code</code>) reports one day's (<code>observation_date</code>) daily-maximum reading (<code>water_level_mm</code>), and that reading should be unique.</p>
<ul>
<li><p>Records from different stations obviously aren't duplicates of each other.</p>
</li>
<li><p>Two readings on different days aren't duplicates.</p>
</li>
<li><p>A subtler point: if the <em>same</em> station reports the <em>same</em> day but with a <em>different</em> value, that counts as a distinct observation, for example a corrected or revised measurement, not a duplicate to silently discard.</p>
</li>
</ul>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>create_dedup_key()</code> builds a unique text ID for each row by joining three pieces together with an underscore:</p>
<ul>
<li><p>station code</p>
</li>
<li><p>date, as YYYY-MM-DD</p>
</li>
<li><p>water level value</p>
</li>
</ul>
<p>Example key:</p>
<pre><code class="language-text">"F700000109_2024-01-15_5800.0"
</code></pre>
<pre><code class="language-python">def create_dedup_key(df: pd.DataFrame) -&gt; pd.Series:
    """Create unique deduplication key from station, day, and water level value.

    Key format: "station_code_YYYY-MM-DD_value"
    Example: "F700000109_2024-01-15_5800.0"
    """
    parts = []

    if "code_station" in df.columns:
        parts.append(df["code_station"].astype(str))

    if "date_obs_elab" in df.columns:
        parts.append(df["date_obs_elab"].dt.strftime("%Y-%m-%d"))

    if "resultat_obs_elab" in df.columns:
        parts.append(df["resultat_obs_elab"].astype(str))

    if not parts:
        return pd.Series(index=df.index, dtype="object")

    return pd.Series(
        ["_".join(row) for row in zip(*parts)],
        index=df.index
    )
</code></pre>
<p><strong>How the key-building works:</strong> <code>parts</code> ends up as a list of <a href="https://www.geeksforgeeks.org/pandas/python-pandas-series/">Series</a>, one per key component (station, date, value), each the same length as the DataFrame.</p>
<p>Read the last line from the inside out:</p>
<pre><code class="language-python">    return pd.Series(["_".join(row) for row in zip(*parts)], index=df.index)
</code></pre>
<p><code>zip(*parts)</code> transposes that list of columns into row-wise tuples. It yields <code>(station_1, date_1, value_1)</code>, then <code>(station_2, date_2, value_2)</code>, and so on. The <a href="https://www.geeksforgeeks.org/python/python-list-comprehension/">list comprehension</a> then joins each row-tuple with underscores into one string key per row.</p>
<p>This <em>"list of columns → zip → row tuples"</em> idiom is a common and efficient way to combine several Series into one derived Series, without writing <code>.apply(lambda row: ..., axis=1)</code>. Row-wise <code>.apply</code> is notoriously <a href="https://stackoverflow.com/questions/54432583/when-should-i-not-want-to-use-pandas-apply-in-my-code">slow in pandas</a> compared to <a href="https://www.datacamp.com/es/tutorial/pandas-iterate-over-rows">vectorized</a> string operations.</p>
<p>The actual deduplication happens in <code>remove_duplicates()</code>. It removes rows from <em>"new"</em> (freshly fetched data) that already exist in <em>"existing"</em> (historic data).</p>
<p>Step by step:</p>
<ol>
<li><p>If one table is empty, it just returns <code>new</code>.</p>
</li>
<li><p>It enforces types in both DataFrames with <code>auto_convert_columns()</code> first, so dates and numbers compare correctly.</p>
</li>
<li><p>It creates a deduplication key for each row in both tables with <code>create_dedup_key()</code>.</p>
</li>
<li><p>It checks which keys from the fetched data don't appear in the existing historic dataset, and keeps only truly new rows.</p>
</li>
<li><p>It returns the filtered result, keeping the original columns from <code>new</code>.</p>
</li>
</ol>
<pre><code class="language-python">def remove_duplicates(existing: pd.DataFrame, new: pd.DataFrame) -&gt; pd.DataFrame:
    """Remove rows from 'new' that already exist in 'existing'."""
    # Short-circuit: if either is empty, no work to do
    if existing.empty or new.empty:
        return new.copy()

    # Parse types on both sides for a fair comparison
    existing_std = auto_convert_columns(existing)
    new_std = auto_convert_columns(new)

    # Build the keys
    existing_keys = set(create_dedup_key(existing_std).dropna())
    new_keys = create_dedup_key(new_std)

    # Boolean mask: True where the new row is genuinely new
    mask = ~new_keys.isin(existing_keys)

    # Index back into the ORIGINAL (non-standardized) 'new' to preserve all columns
    result = new.iloc[new_keys[mask].index].copy()
    return result
</code></pre>
<p>Two performance and robustness details are worth to note:</p>
<p>First, <code>existing_keys</code> <strong>is a</strong> <code>set</code><strong>, not a</strong> <code>list</code><strong>.</strong> Testing membership (<code>in</code> / <code>.isin()</code>) against a Python <code>set</code> is <a href="https://robbell.io/2009/06/a-beginners-guide-to-big-o-notation"><strong>O(1)</strong></a> on average, because it uses a fast hash lookup to answer <em>is this item already here?</em> directly. Testing against a <code>list</code> is <strong>O(n)</strong>: it has to scan item by item and it gets slower as the existing dataset grows.</p>
<p>For a dataset with tens of thousands of rows, checked on every single pipeline run, that difference matters and that's a textbook example of choosing the right data structure for the job. See the general discussion of <a href="https://stackoverflow.com/questions/513882/python-list-vs-dict-for-look-up-table">list vs. set lookup performance in Python</a> and this <a href="https://thelinuxcode.com/pandas-value-list/">guidance</a> on <a href="https://stackoverflow.com/questions/61515457/fastest-way-to-filter-a-pandas-dataframe-using-a-list"><code>.isin()</code> performance for filtering</a>.</p>
<p><strong>Simple rule:</strong> use a <code>set</code> when you care about fast membership checks. Use a <code>list</code> when you care about order or duplicates.</p>
<p>Second, <code>new.iloc[new_keys[mask].index]</code> <strong>indexes back into the <em>original</em>, non-validated</strong> <code>new</code> <strong>DataFrame</strong>, not <code>new_std</code>.</p>
<p>Why? <code>auto_convert_columns()</code> only ran to get <em>consistent types for comparison</em>. The caller still wants the <em>original</em> raw values and schema back for everything that survives deduplication.</p>
<p>Don't let a side-computation <em>accidentally</em> become your source of truth. Always modify a copy only to decide what to keep or remove. Once you've made that decision, apply it to the original data. That way you preserve the real source values for later steps.</p>
<p>In short:</p>
<ol>
<li><p>First modifications are only for comparison.</p>
</li>
<li><p>Use that comparison to filter the original data.</p>
</li>
<li><p>Return the original rows unchanged, so later stages can process them.</p>
</li>
</ol>
<p><strong>Best practice:</strong> keep your deduplication key simple and stable (immutable) and always short-circuit (with <code>if existing.empty or new.empty: return new.copy()</code>) before doing any heavier work. This cheap guard clause skips the expensive deduplication logic when there's no data to process.</p>
<h3 id="heading-put-it-all-together-in-postprocess">Put it All Together in <code>postprocess()</code></h3>
<p>All the pieces above are small and testable on their own. The last step of <strong>Transform</strong> glues them together, <em>in order</em>, into the single pipeline function below:</p>
<pre><code class="language-python">def postprocess(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Apply all post-processing transformations."""
    if df.empty:
        return df

    df = df.copy()

    print("  1. Converting types...")
    df = auto_convert_columns(df)

    print("  2. Renaming columns (French → English)...")
    df = rename_to_english(df)

    print("  3. Mapping categorical values...")
    df = apply_categorical_mappings(df)

    print("  4. Adding derived columns...")
    df = add_derived_columns(df)

    print("  5. Reordering columns...")
    df = order_columns(df)

    print("  6. Sorting and resetting index...")
    df = df.sort_values(["record_date", "station_code"]).reset_index(drop=True)

    return df
</code></pre>
<p>Notice the shape of this function: it's basically a linear script with six numbered, printed steps. Each one calls a previously-defined pure function. There's no new <em>logic</em> here, only <em>sequencing</em>. That's intentional.</p>
<p><strong>Best practice:</strong> log progress clearly at each stage. When a scheduled job fails at 3am, a clear step-by-step log is the difference between a two-minute diagnosis and an hour of guessing. A good option is to use a <a href="https://www.geeksforgeeks.org/python/logging-in-python/">logger</a>. Here, this tutorial sticks with classic console printing (<code>print(f" 1. Converting types...")</code>).</p>
<h2 id="heading-part-6-the-load-step">Part 6: The Load Step</h2>
<h3 id="heading-design-logic">Design Logic</h3>
<p><strong>Load</strong> is the final stage: take the processed data and move it to its destination. Typical destinations include data warehouses, data lakes, and databases.</p>
<p>Before publishing, the pipeline prepares two things: the output folder itself, and a metadata file that describes the dataset.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>create_output_dir()</code> makes sure an output folder exists, and creates one if it doesn't:</p>
<ul>
<li><p>If <code>use_mock</code> is <code>True</code>, it uses <code>KAGGLE_CONFIG.mock_output_dir</code>, the directory for fake data.</p>
</li>
<li><p>Otherwise, it uses the real output directory set in <code>KAGGLE_CONFIG.output_dir</code>.</p>
</li>
</ul>
<pre><code class="language-python">def create_output_dir(use_mock: bool = False) -&gt; None:
    """Create output directory (and parents) if it does not already exist."""
    output_dir = KAGGLE_CONFIG.mock_output_dir if use_mock else KAGGLE_CONFIG.output_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    # exist_ok=True: idempotent, safe to call multiple times
</code></pre>
<p><code>exist_ok=True</code> is a small but important detail. Without it, <a href="https://docs.python.org/3/library/pathlib.html#pathlib.Path.mkdir"><code>Path.mkdir()</code></a> raises <code>FileExistsError</code> if the directory already exists, which it will on every run after the first.</p>
<p>Setting <code>exist_ok=True</code> makes directory creation <strong>idempotent</strong>: calling it 100 times has the same effect as calling it once. On the filesystem, this is the "safe to re-run" idea behind the deduplication logic from Part 5.</p>
<p>The Kaggle API follows the <a href="https://frictionlessdata.io/specs/data-package/">Data Package specification</a>. It <a href="https://github.com/Kaggle/kaggle-cli/wiki/Dataset-Metadata/bc2684f533cd40afae28210d8f6e62b88d793d62">requires</a> a descriptive <code>dataset-metadata.json</code> file alongside the CSV. This matters for the search and discoverability of the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">Paris flood dataset</a>:</p>
<pre><code class="language-python">def create_metadata(df: pd.DataFrame, config: KaggleConfig) -&gt; Dict:
    """Generate Kaggle dataset metadata from DataFrame and config."""
    if df.empty or "record_date" not in df.columns:
        first_date = "unknown"
        last_date = "unknown"
    else:
        first_date = df["record_date"].min().strftime("%Y-%m-%d")
        last_date = df["record_date"].max().strftime("%Y-%m-%d")

    return {
        "title": config.title,
        "id": config.dataset_slug,
        "licenses": [{"name": config.license_name}],
        "keywords": config.keywords,
        "temporalCoverage": {"startDate": first_date, "endDate": last_date},
        "geospatialCoverage": config.geospatial_coverage,
        "updateFrequency": config.update_frequency,
    }
</code></pre>
<p>Metadata comes from the <code>KaggleConfig</code> dataclass. Note that <code>temporalCoverage</code> is computed <em>from the data itself</em> (<code>df["record_date"].min()</code>/<code>.max()</code>) rather than hardcoded. Every time the pipeline runs, the metadata's date range automatically reflects reality, with zero manual bookkeeping.</p>
<p>Finally, <code>publish_to_kaggle()</code> uploads the updated dataset to Kaggle. It calls out to the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a> as an external command.</p>
<p>What it does:</p>
<ol>
<li><p>Gets the current time and turns it into a text label like <code>Weekly update: 2026-07-13 14:30:00</code></p>
</li>
<li><p>Builds a Kaggle command that says:</p>
<ul>
<li><p>publish a new dataset version</p>
</li>
<li><p>use files from <code>KAGGLE_CONFIG.output_dir</code></p>
</li>
<li><p>attach the message</p>
</li>
<li><p>zip the directory contents</p>
</li>
</ul>
</li>
<li><p>Prints the command so you can see what will run</p>
</li>
<li><p>Runs the command with <code>subprocess.run(...)</code></p>
</li>
</ol>
<p>If the upload works:</p>
<ul>
<li>it prints <code>Successfully published to Kaggle.</code></li>
</ul>
<p>If it fails:</p>
<ul>
<li>it prints the error and then raises the error again so the failure isn't hidden.</li>
</ul>
<pre><code class="language-python">def publish_to_kaggle() -&gt; None:
    """Publish the updated dataset to Kaggle using the Kaggle CLI."""
    timestamp = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")
    message = f"Weekly update: {timestamp}"

    cmd = [
        "kaggle", "datasets", "version",
        "-p", str(KAGGLE_CONFIG.output_dir),
        "-m", message,
        "--dir-mode", "zip",
    ]

    print("Publishing to Kaggle...")
    print("Command:", " ".join(cmd))

    try:
        subprocess.run(cmd, check=True)
        print("Successfully published to Kaggle.")
    except subprocess.CalledProcessError as e:
        print(f"Error publishing to Kaggle: {e}")
        raise
</code></pre>
<p><code>publish_to_kaggle</code> calls the Kaggle CLI (Command Line Interface) through Python's <a href="https://docs.python.org/3/library/subprocess.html"><code>subprocess</code></a> module. This is the standard way to run an external command-line tool from a Python pipeline.</p>
<p>Two choices here are worth adopting as general habits, not just for Kaggle:</p>
<ul>
<li><p><code>cmd</code> <strong>is built as a</strong> <code>list</code><strong>, never as a concatenated string.</strong> Pass a list of arguments to <code>subprocess.run</code> skips invoking a shell entirely. That sidesteps <a href="https://portswigger.net/web-security/os-command-injection">shell-injection</a> risks and correctly handles arguments containing spaces or special characters. See the <a href="https://docs.python.org/3/library/subprocess.html#security-considerations">official <code>subprocess</code> security considerations</a>.</p>
</li>
<li><p><code>check=True</code> means that if the Kaggle CLI fails, by exiting with a non-zero status, <code>subprocess.run</code> raises <code>CalledProcessError</code> instead of silently returning. Without <code>check=True</code>, a failed publish would look exactly like a successful one to the rest of the code.</p>
</li>
</ul>
<h2 id="heading-part-7-assemble-the-full-pipeline">Part 7: Assemble the Full Pipeline</h2>
<h3 id="heading-the-global-rehearsal-mock-mode">The Global Rehearsal (Mock Mode)</h3>
<p>The whole pipeline is built and tested in isolation. It's time to wire the blocks into one linear function. First, run it entirely against mock data. Nothing gets published this way. For Demonstration purposes, a CSV filled with artificial data will land in a local <code>mock_output</code> folder.</p>
<pre><code class="language-python">def run_etl_pipeline(use_mock: bool = True) -&gt; pd.DataFrame:
    """Run the complete ETL pipeline: Extract → Transform → Load to CSV."""

    # STEP 1: LOAD EXISTING DATA
    if use_mock:
        loaded_df = pd.DataFrame(mock_api_response)
        loaded_df['date_obs_elab'] = pd.to_datetime(loaded_df['date_obs_elab'])
        loaded_df = rename_to_english(loaded_df)
    else:
        loaded_df = load_csv(KAGGLE_CONFIG.input_csv)

    # STEP 2: DETERMINE UPDATE RANGE
    should_update, start_date = determine_update_range(loaded_df)
    if not should_update:
        return loaded_df   # already current: nothing more to do

    # STEP 3: EXTRACT (FETCH DATA)
    fetched_data = fetch_all_data(start_date, use_mock=use_mock)

    # STEP 4: TRANSFORM I - DEDUPLICATE
    deduped_fetched_data = remove_duplicates(loaded_df, fetched_data)

    # STEP 5: TRANSFORM II - COMBINE (MERGE WITH EXISTING)
    new_df_english = rename_to_english(deduped_fetched_data)
    merged_historical_and_new = pd.concat([loaded_df, new_df_english], ignore_index=True)

    # STEP 6: TRANSFORM III - POST-PROCESS
    processed_records = postprocess(merged_historical_and_new)

    # STEP 7: EXPORT (LOAD TO CSV)
    create_output_dir(use_mock=use_mock)
    output_path = KAGGLE_CONFIG.mock_output_filename if use_mock else KAGGLE_CONFIG.output_csv_path
    processed_records.to_csv(output_path, index=False, sep=",")

    return processed_records
</code></pre>
<p>Every one of the seven steps above corresponds to a function you already built, and tested earlier in this tutorial. <strong>To Assemble them is almost mechanical.</strong> That's always the payoff of composing small, single-responsibility functions:</p>
<pre><code class="language-python">loaded_df                 = load_csv(...)                          # 1. load existing data
should_update, start_date = determine_update_range(...)            # 2. check what's needed
fetched_data               = fetch_all_data(start_date)             # 3. fetch new records
deduped_fetched_data       = remove_duplicates(existing, new_raw)   # 4. deduplicate
merged_historical_and_new  = pd.concat([existing, new_clean])       # 5. merge
processed_records          = postprocess(merged_historical_and_new) # 6. postprocess
processed_records.to_csv(...)                                       # 7. save
write_metadata() + publish_to_kaggle()                               # 8. publish
</code></pre>
<p>Each line's intent is obvious just from reading it left to right. That is exactly the point of good decomposition.</p>
<h3 id="heading-main-pipeline-orchestration"><code>main()</code>: Pipeline Orchestration</h3>
<p><code>main()</code> is the <a href="https://docs.python.org/en/3/library/__main__.html">conductor</a>. It wires every previously-built component together in the right order, just like <code>postprocess()</code> did one level down.</p>
<pre><code class="language-python">def main() -&gt; None:
    """Execute the full Paris Flood Monitoring ETL pipeline.

    EXTRACT
    1. Load the existing dataset from the Kaggle input mount
    2. Determine whether an update is needed (and from what date)
    3. Fetch new data from the Hub'Eau API

    TRANSFORM
    4. Deduplicate against the existing dataset
    5. Combine and post-process (type parsing, translation, derived cols...)

    LOAD
    6. Write the updated CSV and metadata file
    7. Publish the new dataset version to Kaggle

    Exits early if the dataset is already up to date.
    """
    final_dataset = run_etl_pipeline(use_mock=False)
    final_dataset.to_csv(KAGGLE_CONFIG.output_csv_path, index=False)
    write_metadata(final_dataset)

    print("\n[FINAL STEP] Publishing to Kaggle...")
    publish_to_kaggle()

    print("Running post-run validation")
    validate_and_analyze(final_dataset)


if __name__ == "__main__":
    main()
</code></pre>
<h3 id="heading-the-if-name-main-guard">The <code>if __name__ == "__main__":</code> Guard</h3>
<pre><code class="language-python">if __name__ == "__main__":
    main()
</code></pre>
<p>This is one of the most common idioms in Python, and it's worth understanding exactly what it does. Per the <a href="https://docs.python.org/3/library/__main__.html">official Python documentation</a> on <code>__main__</code>:</p>
<ul>
<li><p>Run the file directly (<code>python script.py</code>)</p>
<ul>
<li><p>→ the special variable <code>__name__</code> is set to <code>"__main__"</code></p>
</li>
<li><p>→ the condition is <code>True</code></p>
</li>
<li><p>→ <code>main()</code> executes.</p>
</li>
</ul>
</li>
<li><p><strong>Import</strong> the file as a module elsewhere (<code>import script</code>)</p>
<ul>
<li><p>→ <code>__name__</code> is set to the module's name instead</p>
</li>
<li><p>→ the condition is <code>False</code></p>
</li>
<li><p>→ <code>main()</code> does <strong>not</strong> run automatically.</p>
</li>
</ul>
</li>
</ul>
<p><strong>Best practice:</strong> always guard your entry point this way. It makes the module safely <strong>importable</strong> for testing individual functions, or for reuse in another script, without triggering the full pipeline (including a real publish to Kaggle!) just by importing it.</p>
<h2 id="heading-part-8-go-live-and-switch-to-the-real-api">Part 8: Go Live and Switch to the Real API</h2>
<p>Everything above runs safely against mock data. To point the pipeline at the real Hub'Eau API instead:</p>
<ol>
<li><p>Note the <code>use_mock=False</code> setting above. It threads through <code>APIConfig.use_mock</code>, and the calls to <code>run_etl_pipeline(use_mock=False)</code> and <code>main()</code>.</p>
</li>
<li><p>Make sure <code>KAGGLE_CONFIG.dataset_slug</code> and <code>input_csv</code> point at <strong>your own</strong> Kaggle dataset copy before you use it. You can only publish new versions of a dataset you own. Fork both the <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">notebook</a> and the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">dataset</a>, then update the slug in the <a href="#heading-part-3-manage-configuration-with-dataclasses"><code>KaggleConfig</code></a> section.</p>
</li>
<li><p>Install and authenticate the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a> if you plan to call <code>publish_to_kaggle()</code> outside of a Google Colab or Kaggle notebook.</p>
</li>
</ol>
<p>Everything else needs <strong>zero changes</strong>.</p>
<h3 id="heading-post-run-validation">Post-Run Validation</h3>
<p><code>validate_and_analyze()</code> closes the loop, and it's more than just a nice-to-have. It's a welcome sanity check that runs <em>after</em> the pipeline finishes. The output is a human-readable report covering shape, data types, null counts, summary statistics on <code>water_level_mm</code>, how many flood-alert records turned up, the temporal coverage and per-station record counts.</p>
<p>It doesn't change any data. It exists so that whoever, or whatever monitoring system, reads the run's log output can immediately see whether this week's numbers look sane. No need to analyze the CSV by hand. Try it!</p>
<h2 id="heading-summary">Summary</h2>
<p>You've just built a <em>complete</em> ETL pipeline that you can run again and again without breaking anything. The skeleton is here, and that same pattern repeats for almost any scheduled data job: swap out the API, the field mapping, and the destination.</p>
<p>For more tutorials like this one, check out my <a href="https://github.com/hyperphantasia">GitHub</a> or <a href="https://kaggle.com/grimespoint">Kaggle profile</a>.</p>
<p>Thanks for reading!</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://hubeau.eaufrance.fr/">Hub'Eau API</a>: France's official open water-data platform</p>
</li>
<li><p>The <a href="https://www.kaggle.com/code/grimespoint/paris-flood-dataset-weekly-updater">updater <code>.py</code> script</a> runs live once a week and updates the original <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">dataset</a>.</p>
</li>
<li><p>The source dataset generator is available on <a href="https://github.com/hyperphantasia/paris-flood-dataset">GitHub</a>.</p>
</li>
<li><p>The complete <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">Jupyter notebook</a> to follow this tutorial and code all along (<a href="https://github.com/hyperphantasia/kaggle/blob/main/notebook_archive/data-engineering-with-python-etl-pipeline.ipynb">backup available</a>).</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Free Up and Automatically Manage Disk Space for WSL on Windows 10/11 ]]>
                </title>
                <description>
                    <![CDATA[ Windows Subsystem for Linux (WSL) lets you run a Linux environment directly on Windows. This is particularly useful for web development where you can develop and test applications in a Linux environme ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-free-up-and-automatically-manage-disk-space-for-wsl-on-windows-1011/</link>
                <guid isPermaLink="false">6893e671640b08f689368ee6</guid>
                
                    <category>
                        <![CDATA[ WSL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ wsl2 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk management ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk space ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Powershell ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Windows ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Windows 10 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ windows 11 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ brooklyn ]]>
                </dc:creator>
                <pubDate>Wed, 06 Aug 2025 23:34:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1754523230294/70893973-fddf-42a9-b41a-2a8f94a47e22.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Windows Subsystem for Linux (<a href="https://learn.microsoft.com/en-us/windows/wsl/install">WSL</a>) lets you run a Linux environment directly on Windows. This is particularly useful for web development where you can develop and test applications in a Linux environment without leaving Windows. You can even run <a href="https://contribute.freecodecamp.org/how-to-setup-wsl/">freeCodeCamp locally</a> with it!</p>
<p>But managing disk space can be a quite a challenge, as WSL uses virtual hard disks that do not automatically free up unused space.</p>
<p>This tutorial will guide you through the process of manually compacting your WSL virtual hard disks. We’ll automate this task using a PowerShell script, ensuring that your WSL environment remains efficient and clutter-free.</p>
<h2 id="heading-reclaim-your-space">Reclaim Your Space</h2>
<p>WSL uses a virtualization platform to install Linux distributions on your Windows system. Each distribution you add gets its own Virtual Hard Disk (VHD), which uses the ext4 file system (common in Linux). It’s saved on your Windows drive as an ext4.vhdx file.</p>
<p>Key issues here:</p>
<ul>
<li><p>Inefficient storage: by default, VHD files <strong>do not reclaim</strong> unused space. This means that when you delete a file in WSL, the associated disk space isn’t immediately freed up.</p>
</li>
<li><p>Disk space consumption: due to that inefficient storage, the <strong>VHD files can grow large</strong> thanks to that accumulated data, especially if you’re a WSL heavy user.</p>
</li>
<li><p>Need for maintenance: you may not know that you need to <strong>compact</strong> your VHD files in order to reclaim disk space.</p>
</li>
</ul>
<p>If you notice that your free disk space is shrinking even after deleting files and apps, WSL might be the reason. This tutorial will help you keep your WSL and Windows environment running smoothly.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-part-1-how-to-manually-compact-your-virtual-hard-disk">Part 1: How to Manually Compact Your Virtual Hard Disk</a></p>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-verify-your-wsl-version-and-status">Step 1: Verify your WSL version and status</a></p>
</li>
<li><p><a href="#heading-step-2-list-all-installed-distributions-verbosely">Step 2: List all installed distributions verbosely</a></p>
</li>
<li><p><a href="#heading-step-3-locate-your-linux-virtual-hard-drive-vhdx-path">Step 3: Locate your linux Virtual Hard Drive (VHDX) path</a></p>
</li>
<li><p><a href="#heading-step-4-shut-down-all-wsl-instances">Step 4: Shut down all WSL instances</a></p>
</li>
<li><p><a href="#heading-step-5-compact-the-linux-virtual-hard-drive-using-diskpart">Step 5: Compact the Linux virtual hard drive using DiskPart</a></p>
</li>
<li><p><a href="#heading-step-6-restart-wsl-and-verify">Step 6: Restart WSL and verify</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-how-to-make-your-life-easier-with-automation">Part 2: How to Make Your Life Easier with Automation</a></p>
<ul>
<li><p><a href="#heading-prerequisites-1">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-find-out-installed-wsl2-distributions">Step 1: Find out installed WSL2 distributions</a></p>
</li>
<li><p><a href="#heading-step-2-select-a-distro-to-compact">Step 2: Select a distro to compact</a></p>
</li>
<li><p><a href="#heading-step-3-locate-the-ext4vhdx-file">Step 3: Locate the ext4.vhdx File</a></p>
</li>
<li><p><a href="#heading-step-4-the-confirmation-prompt">Step 4: The confirmation prompt</a></p>
</li>
<li><p><a href="#heading-step-5-shut-down-wsl-and-compact">Step 5: Shut Down WSL and compact</a></p>
</li>
<li><p><a href="#heading-step-6-run-a-diskpart-script">Step 6: Run a DiskPart script</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-part-1-how-to-manually-compact-your-virtual-hard-disk"><strong>Part 1: How to Manually Compact Your Virtual Hard Disk</strong></h2>
<p>Let's start by going through the process manually. This section will guide you through checking your WSL version and associated Linux distributions, finding VHD files, shutting down WSL, and compacting the virtual disk.</p>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<ul>
<li><p>Windows 10 (20H1/2004+) or Windows 11 with WSL2 installed</p>
</li>
<li><p>The PowerShell or Command Prompt running as <strong>Administrator</strong> (from the Windows menu, right click the icon and choose run as Administrator).</p>
</li>
</ul>
<h3 id="heading-step-1-verify-your-wsl-version-and-status"><strong>Step 1: Verify your WSL version and status</strong></h3>
<p>First, make sure you’re running on WSL version 2 (commonly referred as WSL2). The first version is outdated and WSL2 provides significant improvements. Open PowerShell (as Admin) or Command Prompt (as Admin) and run:</p>
<pre><code class="language-powershell">wsl -v

wsl --status
</code></pre>
<p>These commands display the WSL client version and whether your default distro is using WSL 2. Here’s the output of the <code>wsl -v</code> command:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250376279/ef11af4b-ba5b-43f9-9532-db2634eed154.png" alt="Command prompt displaying WSL version 2.5.9.0, with corrupted or incomplete text following &quot;Kernel version:&quot;, &quot;WSLg version:&quot;, and other version labels." width="600" height="400" loading="lazy">

<p>And here’s the output of the <code>wsl --status</code> command.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250364365/6cea2d97-0796-4320-8f84-58d1b5e62c5e.png" alt="Command line text showing &quot;C:sers>wsl --status&quot; with the information &quot;Default Distribution: Ubuntu&quot; and &quot;Default Version: 2&quot;." width="600" height="400" loading="lazy">

<h3 id="heading-step-2-list-all-installed-distributions-verbosely"><strong>Step 2: List all installed distributions verbosely</strong></h3>
<p>To see a detailed list of your WSL distributions (including which version each uses), run:</p>
<pre><code class="language-powershell">wsl.exe --list --verbose
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250281542/1826814a-5516-483e-b8ab-6477fd950e21.png" alt="Output of  the WSL --list --verbose command." width="600" height="400" loading="lazy">

<p>Above you can see the output of the WSL <code>--list --verbose</code> command.</p>
<p>Look for your distro name (for example, “<em>Ubuntu</em>”) and note its WSL version. If it shows “Version 2”, you can proceed with compaction.</p>
<h3 id="heading-step-3-locate-your-linux-virtual-hard-drive-vhdx-path"><strong>Step 3: Locate your linux Virtual Hard Drive (VHDX) path</strong></h3>
<p>Each WSL distro’s files live in a <a href="https://en.wikipedia.org/wiki/VHD_(file_format)">VHDX file</a> on your Windows drive. To find the path for any Linux distribution, use this PowerShell snippet:</p>
<pre><code class="language-powershell">(Get-ChildItem `

-Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss `

| Where-Object { $_.GetValue("DistributionName") -eq 'YOUR_DISTRO_NAME' }

).GetValue("BasePath") + "\ext4.vhdx"
</code></pre>
<p>Where you replace <code>YOUR_DISTRO_NAME</code> with yours (Ubuntu, Debian, Kali-linux..). Here’s the output of the command shown above in PowerShell (the filepath has been anonymized):</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251303855/ea3b3880-5804-4f50-97c8-327ffd017084.png" alt="A PowerShell command is displayed, used to locate the ext4.vhdx file for the Ubuntu distribution. The command retrieves the Windows Subsystem for Linux (WSL) base path for Ubuntu." width="600" height="400" loading="lazy">

<p>This command reads the registry key for your linux distribution, then appends “\ext4.vhdx” to build the full file path.</p>
<p>Make sure you copy the whole line. We will need it in later stages.</p>
<h3 id="heading-step-4-shut-down-all-wsl-instances"><strong>Step 4: Shut down all WSL instances</strong></h3>
<p>Before you can compact any virtual drive, make sure WSL is completely shut down. In PowerShell or Command Prompt (still as Administrator), run:</p>
<pre><code class="language-powershell">wsl.exe --shutdown
</code></pre>
<h3 id="heading-step-5-compact-the-linux-virtual-hard-drive-using-diskpart"><strong>Step 5: Compact the Linux virtual hard drive using DiskPart</strong></h3>
<p>You successfully gathered all the needed information (about your system, the available distros, and their VHDX filepath) to proceed with the main task. In this step, you actually proceed with the compaction.</p>
<ol>
<li>Launch DiskPart in the same elevated (<em>admin</em>) shell:</li>
</ol>
<pre><code class="language-powershell">diskpart
</code></pre>
<p>DiskPart will open in a new window. It's a Windows command-line tool for managing disk partitions. Be cautious when using it, as incorrect actions can cause serious data loss.</p>
<ol>
<li>In the DiskPart prompt, select the VHDX file you found earlier. Replace the path as displayed below with your actual path (the line you copied before):</li>
</ol>
<pre><code class="language-powershell">select vdisk file="C:\Users\username\AppData\path\to\ext4.vhdx"
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251748072/91795798-0896-4fcc-994c-0ab311955bee.png" alt="Screenshot of a command prompt window showing Microsoft DiskPart version information. A command is entered to select a virtual disk file, and a message confirms successful selection." width="600" height="400" loading="lazy">

<p>The above is the output of the select vdisk command (some data has been anonymized).</p>
<ol>
<li>Attach the virtual drive in read-only mode:</li>
</ol>
<p>Compaction only needs to scan the empty blocks in the file, not write to the Linux filesystem inside. Read-only mode guarantees that DiskPart only inspect the blocks for zero‐trimming without any chance of damaging or altering your Linux filesystem.</p>
<pre><code class="language-powershell">attach vdisk readonly
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251937734/a90af720-1511-42cc-bc37-63439d855907.png" alt="Command prompt showing the output for &quot;DISKPART> attach vdisk readonly&quot; with a successful attachment message." width="600" height="400" loading="lazy">

<p>You can see in the screenshot above that the virtual hard drive has been successfully attached.</p>
<ol>
<li>Compact the disk:</li>
</ol>
<p>This frees up the disk space by shrinking the physical size of the <code>.vhdx</code> file to match the <strong>actual used data</strong> inside.</p>
<pre><code class="language-powershell">compact vdisk
</code></pre>
<p>This operation might take a while. When you see the <code>“DiskPart successfully compacted the virtual disk file”</code> message, proceed with the next step. In the image below, the virtual hard drive has been successfully compacted.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754252148605/51cc9739-775b-4a84-a526-7c2a6d2a9722.png" alt="Terminal output showing &quot;DISKPART> compact vdisk&quot; with &quot;100 percent completed,&quot; indicating successful compaction of the virtual disk file." width="600" height="400" loading="lazy">

<ol>
<li>Detach the virtual drive:</li>
</ol>
<pre><code class="language-powershell">detach vdisk
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754252460797/e29387d7-4f03-4ddb-80f7-f14a5051b0fe.png" alt="Command prompt showing &quot;DISKPART> detach vdisk&quot; and a confirmation message that DiskPart successfully detached the virtual disk file." width="600" height="400" loading="lazy">

<p>There you go – the virtual hard drive has been successfully detached.</p>
<p>This command releases any locks on the virtual drive and effectively dismounts it. If you don't use this command, the file remains "in use," preventing WSL (or you) from accessing it until you reboot or manually force it closed.</p>
<p>6. Exit DiskPart:</p>
<pre><code class="language-powershell">exit
</code></pre>
<h3 id="heading-step-6-restart-wsl-and-verify"><strong>Step 6: Restart WSL and verify</strong></h3>
<p>Back in PowerShell or Command Prompt, you can relaunch your distro:</p>
<pre><code class="language-powershell">wsl -d YOUR_DISTRO_NAME
</code></pre>
<p>You can even try the Unix <code>df -h</code> command in your WSL prompt to check your new available disk spaces.</p>
<p>Congrats, you just achieved a maintenance task that can free up lots of gigabytes of storage over time. Now, it’s time to automate.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754321783829/4325a626-806e-47e7-9147-a76f4c91a93a.jpeg" alt="A minimalistic white rectangular button with a cable on a pink surface." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-part-2-how-to-make-your-life-easier-with-automation"><strong>Part 2: How to Make Your Life Easier with Automation</strong></h2>
<p>Since it's often hard to remember exactly where your WSL distro is located and you probably won't use it very often, this PowerShell script will automate the entire process we covered in part 1. Here's a preview of the steps you'll follow:</p>
<ul>
<li><p>Detect installed WSL distributions.</p>
</li>
<li><p>Select one (and handle the cases there are more than one).</p>
</li>
<li><p>Locate the corresponding <code>ext4.vhdx</code> file.</p>
</li>
<li><p>Shut down WSL and use DiskPart to compact the virtual disk.</p>
</li>
</ul>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<ul>
<li><p>Windows 10 (20H1/2004+) or Windows 11 with WSL 2 enabled.</p>
</li>
<li><p>PowerShell or Command Prompt (as Administrator).</p>
</li>
</ul>
<p>You’ll also need a code editor. The Windows notepad is enough for completing this task. You can also use an IDE (Integrated Development Environment) like VS Code or an ISE (Integrated Scripting Environment) like PowerShell ISE (included with Windows).</p>
<p>To test the script, download it from <a href="https://github.com/hyperphantasia/WSL-VHDX-Compact/blob/c5c2e346ab0dd1a8dbc6130f8d372af8022ddd60/wsl_compactor.ps1">GitHub</a>. Open an elevated PowerShell or Command Prompt and navigate to the script’s folder. With just the command below, you will be able to run it and free up some disk space:</p>
<pre><code class="language-powershell">powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\wsl_compactor.ps1
</code></pre>
<h3 id="heading-step-1-find-out-installed-wsl2-distributions"><strong>Step 1: Find out installed WSL2 distributions</strong></h3>
<p>One of the main challenges is to find the Linux distributions available on the host system. Let’s check the first block and see what’s it about:</p>
<pre><code class="language-powershell">$lxssKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss'
\(distros = Get-ChildItem \)lxssKey | ForEach-Object {
    \(p = Get-ItemProperty \)_.PSPath
    [PSCustomObject]@{
        Name     = $p.DistributionName
        BasePath = $p.BasePath
    }
}
</code></pre>
<p>WSL records each distribution under this windows registry key:</p>
<p><code>HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss</code></p>
<p>Each subkey has two important values:</p>
<ul>
<li><p><strong>DistributionName</strong> (for example, Ubuntu)</p>
</li>
<li><p><strong>BasePath</strong> (This is where the distribution files are stored. It’s the directory that contains the <code>ext4.vhdx</code> file.)</p>
</li>
</ul>
<p>The script uses <code>Get-ChildItem</code> and <code>Get-ItemProperty</code> to enumerate these subkeys and build a list of available Linux distributions.</p>
<pre><code class="language-powershell">if ($distros.Count -eq 0) {
Throw-And-Exit "No WSL distros found in the registry."
}
</code></pre>
<p>If no distributions are found, the script terminates and prints this error message on the terminal: <code>"No WSL distros found in the registry.”</code></p>
<h3 id="heading-step-2-select-a-distro-to-compact"><strong>Step 2: Select a distro to compact</strong></h3>
<p>Here, the process has two steps:</p>
<ul>
<li>If multiple distros are found, it displays all the distros with a numbered menu and prompts you to choose one:</li>
</ul>
<pre><code class="language-powershell">if ($distros.Count -gt 1) {
    Write-Host "Multiple distros detected. Please choose one:`n"

    for (\(i = 0; \)i -lt \(distros.Count; \)i++) {
        Write-Host "[\((\)i+1)] \((\)distros[$i].Name)"
    }
    \(selected = \)distros[[int]$choice - 1]
}
</code></pre>
<p>The computed menu will look like this:</p>
<pre><code class="language-markdown">Multiple distros detected. Please choose one:

[1] Ubuntu 20.04

[2] Debian

[3] Alpine
</code></pre>
<ul>
<li>If only one distribution is found on the host system, the script selects it automatically:</li>
</ul>
<pre><code class="language-powershell">else {
\(selected = \)distros[0]
}
</code></pre>
<p>When setting up a distribution, whether chosen manually by the user or selected automatically, <strong>the important information is the path to each distribution's virtual hard disk</strong>. This path is saved in two main variables: <code>'distro'</code> (which identifies the specific distribution) and <code>'basePath'</code> (which shows where its virtual disk is located).</p>
<pre><code class="language-powershell">\(distro = \)selected.Name
\(basePath = \)selected.BasePath

Write-Host "`nSelected distro: $distro" -ForegroundColor DarkYellow
Write-Host "BasePath: $basePath"
</code></pre>
<p>The lines above display an output that looks like this:</p>
<pre><code class="language-markdown">Selected distro: Ubuntu (or any other distro)
BasePath: C:\Users\&lt;User_name&gt;\AppData\Local\Packages\…
</code></pre>
<p>Like for all other steps, it’s important to consider the case of something going wrong, by throwing an error and exiting the program:</p>
<pre><code class="language-powershell">if (-not (Test-Path $basePath)) {
Throw-And-Exit "BasePath '$basePath' does not exist on disk."
}
</code></pre>
<h3 id="heading-step-3-locate-the-ext4vhdx-file"><strong>Step 3: Locate the ext4.vhdx File</strong></h3>
<p>In the first step, we collected the information we need about the available distributions and where they are stored on the Windows system. By choosing an entry (either manually or automatically), we can find the correct file. Sometimes, the ext4 file is located between the base path and a <em>LocalState</em> folder. This script manages both situations. It builds the usual locations where the file can be found.</p>
<p>They look like this:</p>
<ul>
<li><p><code>$BasePath\ext4.vhdx</code></p>
</li>
<li><p><code>$BasePath\LocalState\ext4.vhdx</code></p>
</li>
</ul>
<p>This can translate into something like this on your system (option 1):</p>
<pre><code class="language-markdown">C:\Users\Alice\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu20.04onWindows_79rhkp1fndgsc\ext4.vhdx
</code></pre>
<p>or like this (option 2):</p>
<pre><code class="language-markdown">C:\Users\Alice\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu20.04onWindows_79rhkp1fndgsc\LocalState\ext4.vhdx
</code></pre>
<p>(You might find out that your WSL2 distro is located in some other directory than “Packages” – but don’t worry, your BasePath will match the correct folders).</p>
<p>The idea is to build the two possible path options:</p>
<pre><code class="language-powershell">$possible = @(
Join-Path $basePath 'ext4.vhdx'
Join-Path $basePath 'LocalState\ext4.vhdx'
)
</code></pre>
<p>And pick the first one that actually contains the file:</p>
<pre><code class="language-powershell">\(vhdx = \)possible | Where-Object { Test-Path $_ } | Select-Object -First 1
</code></pre>
<p>Again, we throw an error message if no suitable file is found:</p>
<pre><code class="language-powershell">if (-not $vhdx) {
Throw-And-Exit "No ext4.vhdx found under '$basePath'."
}
</code></pre>
<h3 id="heading-step-4-the-confirmation-prompt"><strong>Step 4: The confirmation prompt</strong></h3>
<p>Disk management tools require caution and you need to understand the potential consequences of your actions. A confirmation prompt is always a good safeguard to prevent accidental data loss or unwanted system changes.</p>
<p>Before proceeding, the script shows you:</p>
<ul>
<li><p>a Distro name</p>
</li>
<li><p>its BasePath</p>
</li>
<li><p>the VHDX file path</p>
</li>
</ul>
<pre><code class="language-powershell">Write-Host "`nAbout to compact this WSL distro:" -ForegroundColor Magenta
Write-Host " Distro : $distro"
Write-Host " BasePath : $basePath"
Write-Host " VHDX file: $vhdx`n"
</code></pre>
<p>It then prompts <strong>“Are you sure you want to proceed? (Y/N)”:</strong></p>
<pre><code class="language-powershell">Write-Host "Are you sure you want to proceed? (Y/N) " -ForegroundColor DarkCyan -NoNewline

# Then read the response
$answer = Read-Host
</code></pre>
<p>You’re then prompted to Type Y (case-insensitive) to continue or anything else to cancel.</p>
<pre><code class="language-powershell">if ($answer.ToUpper() -ne 'Y') {
    Write-Warning "Operation canceled"
    exit
}
</code></pre>
<p>For the two steps above, I had to use a trick to print the question in color but a simple option (without colors) could be:</p>
<pre><code class="language-powershell">if ((Read-Host 'Are you sure you want to proceed? (Y/N)').ToUpper() -ne 'Y') { 
    Write-Warning 'Operation canceled'
    exit 
}
</code></pre>
<h3 id="heading-step-5-shut-down-wsl-and-compact"><strong>Step 5: Shut down WSL and compact</strong></h3>
<p>Before proceeding to the DiskPart utility, it’s important to stop all running WSL instances. Pass the shutdown command directly in PowerShell.</p>
<pre><code class="language-powershell">Write-Host "Shutting down WSL…" -ForegroundColor Cyan
wsl.exe –shutdown
</code></pre>
<p>A common mistake is to forget to launch PowerShell or the Command Prompt with Administrator rights. You can prevent this case with a message:</p>
<pre><code class="language-powershell">if ($LASTEXITCODE -ne 0) {
     Throw-And-Exit "Failed to shut down WSL (exit code $LASTEXITCODE). Are you running as Administrator?"
}
</code></pre>
<h3 id="heading-step-6-run-a-diskpart-script"><strong>Step 6: Run a DiskPart script</strong></h3>
<h4 id="heading-building-the-script">Building the script:</h4>
<p>The process is the same as in the manual part, but this time, we ‘inject’ the ready-to-go DiskPart commands into the script.</p>
<pre><code class="language-powershell">$dpScript = @"
select vdisk file="$vhdx"
attach vdisk readonly
compact vdisk
detach vdisk
exit
"@
</code></pre>
<p>Before launching, there are two steps you need to take:</p>
<ol>
<li>The PowerShell script writes the lines above to a temporary file:</li>
</ol>
<pre><code class="language-powershell">$tempFile = [IO.Path]::GetTempFileName()
Set-Content -LiteralPath \(tempFile -Value \)dpScript -Encoding ASCII
</code></pre>
<p>This is the equivalent to the commands passed in the manual part:</p>
<p><code>select vdisk file="</code><a href="/home/C:/"><code>C:\</code></a><code>…\ext4.vhdx" # full path to the vdisk file</code></p>
<p><code>attach vdisk readonly</code></p>
<p><code>compact vdisk</code></p>
<p><code>detach vdisk</code></p>
<p><code>exit</code></p>
<ol>
<li>Compacting can take a while, especially if you’ve never de-cluttered your virtual drive before. It’s wise to show a warning before proceeding:</li>
</ol>
<pre><code class="language-powershell">Write-Host "Running DiskPart to compact the VHDX. Be patient, this might take a while..." -ForegroundColor Cyan
</code></pre>
<h4 id="heading-invoke-diskpart"><strong>Invoke DiskPart:</strong></h4>
<pre><code class="language-powershell"># Run DiskPart with the script saved to the temporary file and process each output line as it arrives
diskpart /s $tempFile | ForEach-Object {
    # Grab any "NN percent" type message from the line
    if ($_ -match '(\d+)\s+percent') {
        # Only print when the percentage actually changes
        Write-Host "\((\)Matches[1])% completed"
    }
    else {
        # Just echo all over line-types, verbatim
        Write-Host $_
    }
}
</code></pre>
<p>Several points to note here:</p>
<ul>
<li><p>It runs <code>diskpart /s $tempFile</code>: DiskPart reads and executes commands from the temporary file into the PowerShell loop for on-the-fly processing.</p>
</li>
<li><p>For a better user-experience: the snippet below does the trick of filtering out repeated status values by comparing <code>\(pct</code> with the sentinel <code>\)lastPct</code>, and only writing new lines when they differ.</p>
</li>
</ul>
<p>How?</p>
<p>Before entering the loop, we initialize:</p>
<pre><code class="language-powershell">$lastPct with -1
$lastPct = -1 # We initiate a sentinel value
</code></pre>
<p>We are having a guaranteed “first” value that no real percent (0–100) will equal. That way, as soon as you see the first 0 percent, 10 percent, or whatever, it differs from -1.</p>
<p>Then:</p>
<pre><code class="language-powershell">if ($_ -match '(\d+)\s+percent') {
    # Print only when the percentage changes
    Write-Host "\((\)Matches[1])% completed"
}
</code></pre>
<p>This guarantees that on the very first percentage update (say “0 percent” or “10 percent”), <code>\(pct –ne \)lastPct</code> will be <code>true</code>, so it emits the first line. Afterwards, <code>$lastPct</code> holds the last real percentage, and it only prints again when a new, different progress percentage comes in.</p>
<p>The output looks more clean:</p>
<pre><code class="language-markdown">10% completed
20% completed
…
</code></pre>
<p>Otherwise, it’ll flood the screen with dozens of identical “20 percent completed” (for example) updates.</p>
<p>Of course we handle the case of other values (non percent lines) normally:</p>
<pre><code class="language-powershell">else {
    # non-percent lines: print verbatim
    if ($_ -match '\S') {
        Write-Host $_
    }
}
</code></pre>
<p>Once the process is done, don’t forget to clean up the tempfile.</p>
<pre><code class="language-powershell">Remove-Item $tempFile -ErrorAction SilentlyContinue
</code></pre>
<p>By the end of the process, you should see something like this</p>
<pre><code class="language-markdown">Leaving DiskPart...
</code></pre>
<p>Okay, that’s it for the scripting! If you collected all the snippets so far, just save them with a <code>.ps1</code> file extension, or download the full example from this <a href="https://github.com/hyperphantasia/WSL-VHDX-Compact">GitHub repository</a>.</p>
<h3 id="heading-usage"><strong>Usage:</strong></h3>
<p>You now have a complete understanding of what's happening. Ready to get started? In Windows, open the Command Prompt or PowerShell <strong>as an administrator.</strong></p>
<ul>
<li><p>Navigate to the directory containing your <code>.ps1</code>&nbsp;script.</p>
</li>
<li><p>Execute the script with the following command, replacing <code>&lt;File_name_here&gt;</code> with your actual file name:</p>
</li>
</ul>
<pre><code class="language-powershell">powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\&lt;File_name_here&gt;.ps1
</code></pre>
<p>The <code>-NoProfile -ExecutionPolicy Bypass</code> parameters launch PowerShell in a clean, unrestricted environment that ignores user-specific settings and allows script execution without security restrictions. Don’t worry, in this case, it’s okay to do that.</p>
<p><em>Wait…wait…wait...</em></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754315563256/a709192a-6626-4a12-b411-46c8591c5eb6.jpeg" alt="Screenshot of a command line interface showing the execution of a PowerShell script to compact a WSL (Windows Subsystem for Linux) distro. The script confirms the selected distro &quot;Ubuntu&quot; and proceeds to compact the VHDX file using DiskPart. Progress is shown in percentage increments, with final messages indicating successful completion of the compacting process." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Well bravo! You’ve just reclaimed all that unused space inside your WSL2 (almost) hands free!</p>
<p>Now, you can take it a step further by modifying this script to run completely automatically, without the confirmation prompt (step 4). You can also schedule it as a regular maintenance task using a task scheduler:</p>
<pre><code class="language-powershell">schtasks /create /tn "Schedule_name" /tr "powershell.exe -ExecutionPolicy Bypass -File C:\path\to\script.ps1" /sc monthly /d 15 /st 09:00
</code></pre>
<p>This is an example for a monthly execution where <code>/d 15</code> means 15th of each month and <code>/st 09:00</code> is a start time set at 9 am.</p>
<p>That's it! Remember, regular maintenance, whether you do it manually or automatically, is essential to prevent unnecessary disk space usage and ensure a smooth experience with WSL.</p>
<h3 id="heading-thanks-for-reading">Thanks for reading!</h3>
<p>You can find a list of my current projects on <a href="https://github.com/hyperphantasia?tab=repositories">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Protect Your GitHub Repos Against Malicious Clones ]]>
                </title>
                <description>
                    <![CDATA[ The world of open-source development comes with various cyber threats. GitHub is still facing a type of attack that is ongoing since last year where attackers mirrored a huge number of repositories. S ]]>
                </description>
                <link>https://www.freecodecamp.org/news/protect-github-repos-from-malicious-clones/</link>
                <guid isPermaLink="false">68781639d48174ae283f8a5b</guid>
                
                    <category>
                        <![CDATA[ repo confusion ]]>
                    </category>
                
                    <category>
                        <![CDATA[ repository confusion ]]>
                    </category>
                
                    <category>
                        <![CDATA[ fake repos ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ clone ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Malware ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Supply Chain Attack ]]>
                    </category>
                
                    <category>
                        <![CDATA[ malicious ]]>
                    </category>
                
                    <category>
                        <![CDATA[ checklist ]]>
                    </category>
                
                    <category>
                        <![CDATA[ phishing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ infostealer ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Prevention ]]>
                    </category>
                
                    <category>
                        <![CDATA[ best practices ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ brooklyn ]]>
                </dc:creator>
                <pubDate>Wed, 16 Jul 2025 21:14:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1752700407765/5fe06816-3d3a-40e4-8a4e-5cfe96a22368.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The world of open-source development comes with various cyber threats. GitHub is still facing a type of attack that is ongoing since last year where attackers mirrored a huge number of repositories. So as it turns out…the clone wars are not over!</p>
<p>If you haven’t heard about what’s going on:</p>
<blockquote>
<p>GitHub is struggling to contain an ongoing attack that’s flooding the site with with millions of code repositories. These repositories contain obfuscated malware that steals passwords and cryptocurrency from developer devices. … The result is millions of forks with names identical to the original one.</p>
<p>–&nbsp;Dan Goodin, <a href="https://arstechnica.com/security/2024/02/github-besieged-by-millions-of-malicious-repositories-in-ongoing-attack/">Ars technica</a></p>
</blockquote>
<p>Because search engines and GitHub’s own search rankings favor recent activity, these cloned repositories often float to the top – then they lure unsuspecting developers into pulling code that may contain malware.</p>
<p>One of my <a href="http://github.com/hyperphantasia/miniature-fortnight">repositories</a> has been targeted by such an attack, prompting me to monitor it closely. This guide offers tips to spot malicious repository clones before they catch you off guard.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ol>
<li><p><a href="#heading-what-is-a-repository-confusion-attack">What is a Repository Confusion Attack?</a></p>
<ul>
<li><a href="#heading-supply-chain-attacks">Supply Chain Attacks</a></li>
</ul>
</li>
<li><p><a href="#heading-basic-mitigation-strategies">🛡️ Basic Mitigation Strategies</a></p>
<ul>
<li><p><a href="#heading-verify-the-contributors-profiles">Verify the contributors profiles</a></p>
</li>
<li><p><a href="#heading-search-for-clone-repositories">Search for clone repositories</a></p>
</li>
<li><p><a href="#heading-examine-the-commit-pattern">Examine the commit pattern</a></p>
</li>
<li><p><a href="#heading-examine-the-commit-history">Examine the commit history</a></p>
</li>
<li><p><a href="#heading-examine-the-commit-contents">Examine the commit contents</a></p>
</li>
<li><p><a href="#heading-compare-the-concerned-files">Compare the concerned files</a></p>
</li>
<li><p><a href="#heading-some-information-about-the-malware">Some information about the malware</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-action-time">Action Time</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-more-resources">More Resources</a></p>
</li>
</ol>
<h2 id="heading-what-is-a-repository-confusion-attack"><strong>What is a Repository Confusion Attack?</strong></h2>
<p>A repository confusion attack involves:</p>
<ul>
<li><p>Cloning legitimate repositories.</p>
</li>
<li><p>Injecting malicious code into the clone.</p>
</li>
<li><p>Uploading the clone.</p>
</li>
<li><p>Spreading through various unaware actors.</p>
</li>
</ul>
<h3 id="heading-supply-chain-attacks"><strong>Supply Chain Attacks</strong></h3>
<p>If you search for repository confusion on the internet, you'll find out it's a type of <em>supply chain attack</em>.</p>
<p>A supply chain attack is an&nbsp;<em>indirect</em>&nbsp;threat where hackers try infiltrating a system by targeting a trusted third-party or software component, rather than attacking the primary target directly.</p>
<p>It's not the first time this has happened. Before GitHub was targeted, PyPI was attacked in 2023 with&nbsp;<a href="https://arstechnica.com/information-technology/2023/01/more-malicious-packages-posted-to-online-repository-this-time-its-pypi/">fake packages</a>&nbsp;posing as legitimate. These packages lured negligent pip users into downloading malicious payloads (containing in most cases&nbsp;<a href="https://en.wikipedia.org/wiki/Infostealer">infostealer malware</a>).</p>
<h2 id="heading-basic-mitigation-strategies"><strong>🛡️ Basic Mitigation Strategies</strong></h2>
<p><strong>Before</strong>&nbsp;using any repository, make sure you follow these steps and take these precautions.</p>
<h3 id="heading-verify-the-contributors-profiles"><strong>Verify the contributors profiles</strong></h3>
<p>That's a first check: if you see a rather empty GitHub profile&nbsp;– one without reputation that contains just one repository but with a lot of daily commits to it – well, that's a bit suspicious.</p>
<p>In the fake repository, the original author will be listed as a contributor, too. Check that profile. You should be able to find the legitimate repository and do some comparisons.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752335573817/c39aca11-2605-47a2-8a6b-aded16547783.png" alt="GitHub screenshot of a repository contributors" width="316" height="156" loading="lazy">

<p>In the above screenshot you can see solotech143, my evil doppelgänger <em>(he’s been taken down since)</em>.</p>
<h3 id="heading-search-for-clone-repositories"><strong>Search for clone repositories</strong></h3>
<p>You can do a GitHub search by repository name and sort the results by most recent first. Malicious repositories tend to appear at the top of the search results because they are updated more frequently. The original repository might be hidden deeper in the search results.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752335785307/943c6dd3-aa28-4d72-b63a-65736de06dcf.png" alt="GitHub clone search results." width="1172" height="314" loading="lazy">

<p>It’s like clone wars.</p>
<p><strong>This is where it’s dangerous:</strong> users generally click on the first few search results, and in that type of attack, you’re almost guaranteed to see the attacker’s fake repository at the top of the results. The attacker achieves that by giving the fake repository regular fresh commits (and sometimes even a few stars!).</p>
<p>In my case, the original repository is a submission for the HackaViz 2025 competition. Hackathons offer a good attack surface because, beyond the fact they draw niche communities, they are also time sensitive.</p>
<p>Now, let’s move forward a year and imagine Hackaviz 2026 is starting soon. The attacker has easily outranked the untouched original submission. Which repository is most likely to be visited when future competitors – unaware of the scam – will look for the previous submissions?</p>
<h3 id="heading-examine-the-commit-pattern"><strong>Examine the commit pattern</strong></h3>
<p>Here’s when things take a weird turn. Malicious clones are run by automated agents, so the commit history fits a pattern that is rather unusual for a human. Of course, you can automate for many legitimate reasons but… this will always follow a clear goal and there will always be a human-touch at some point. In this case, commits are not adding up.</p>
<p>Let's see how that looks in the screenshots below:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752335872381/1238dee9-3568-4d2b-88bb-f63258ffb045.png" alt="1238dee9-3568-4d2b-88bb-f63258ffb045" width="390" height="197" loading="lazy">

<p>Regular like a clock...</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752335891381/77f835fe-cccf-409f-85d7-789f918d4aa3.png" alt="A GitHub screenshot of a very active contribution activity.." width="764" height="602" loading="lazy">

<p>... and hyperactive!</p>
<h3 id="heading-examine-the-commit-history"><strong>Examine the commit history</strong></h3>
<p>You can’t! And that's the weird part. You're just able to see the last and the initial commit. So why is it hiding all of them? Do you like it when someone hide things from you?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752336385127/6274dd87-0a97-4c38-8849-9d547b9edb22.png" alt="A github commits history screenshot for one day." width="1304" height="225" loading="lazy">

<p>For July 10th, we should be able to see 11 commits, where are the ten others?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752336355084/4c7c343d-2000-4359-ae98-dcc98fb08732.png" alt="A github commits history screenshot for a whole period." width="1307" height="369" loading="lazy">

<p>Well, you can only check the first and last commit. That is not a lot for a repository that has more than 2000 commits registered.</p>
<h3 id="heading-examine-the-commit-contents"><strong>Examine the commit contents</strong></h3>
<p>Well, since I can always check the last commit, I checked some of them. They share the same pattern: the bot is constantly looping over the README file doing the same modifications. As you can see in the screenshot below, it’s updating the file with links to an infected release.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752336493881/e8b57b4c-4d13-44f8-bca6-bd8fbad2738c.png" alt="A github screenshot of commits to a malicious repository." width="1382" height="800" loading="lazy">

<p>Above you can see an AI agent stuck in the Readme loop of change.</p>
<p>Human edits are more varied. In a human-driven project, you will see a large mix of commits: feature commits, exploratory experiments, bug fixes, styling tweaks, and sometimes reverts. A bot clone will often just overwrite files, bump versions, or re-inject the same malicious payload repeatedly with no real contribution to the codebase.</p>
<h3 id="heading-compare-the-concerned-files"><strong>Compare the concerned files</strong></h3>
<p>This is where common sense comes handy. So, you have two README's:</p>
<ol>
<li><p>The&nbsp;<a href="https://web.archive.org/web/20250711182419/https://github.com/solotech143/miniature-fortnight/blob/main/README.md">first</a>&nbsp;consists of AI-generated content that is cluttered with emojis and low-value information. It is designed solely to entice you into clicking the download link of the release.</p>
</li>
<li><p>The <a href="https://github.com/hyperphantasia/miniature-fortnight/blob/main/README.md">other</a> follows <a href="https://www.freecodecamp.org/news/how-to-write-a-good-readme-file/">best practices</a> for creating a good README file. It is accurate and well-structured and functions as a valuable helper and explainer to the code. It also goes deep into the most important aspects of the project. This is usually a good sign that a repository is organic and genuine.</p>
</li>
</ol>
<h3 id="heading-some-information-about-the-malware"><strong>Some information about the malware</strong></h3>
<p>What do we have so far? Well, a suspicious link in a phishy, AI-generated README file that is consistent with a very suspicious pattern in the commit history.</p>
<p>Now, let’s have a closer look at that dubious release and let’s see what an online antivirus scanner might reveal about it.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752336589656/124aecf2-39e9-4158-9a06-f0fd59cbf8c1.png" alt="A  github screenshot of commits to a malicious repository." width="1216" height="404" loading="lazy">

<p>The malware is packed only in the miniature-fortnight-v1.7.6.zip release.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1752336609780/7eaf7fc8-73f2-4d9d-b169-1d5c50ce84f2.png" alt="A malware analysis result." width="1317" height="757" loading="lazy">

<p>Above you can see the result of a scan with an online scanner.</p>
<p>The .zip file contains <strong>only</strong> four files:</p>
<ul>
<li><p>config.txt</p>
</li>
<li><p>launch.bat</p>
</li>
<li><p>lua51.dll</p>
</li>
<li><p>luajit.exe</p>
</li>
</ul>
<p>These files are <strong>totally unrelated</strong> to the source project (a Python data science project with Jupyter notebooks combined to a React app using three.js).</p>
<p>I will not go into the detail in this article. But for the curious ones, it's an infostealer malware (a malware that will exfiltrate your credentials and other precious information about your configuration) similar to the one described in&nbsp;<a href="https://www.trendmicro.com/en_us/research/25/c/ai-assisted-fake-github-repositories.html#">detail here</a>.</p>
<h2 id="heading-action-time"><strong>Action Time</strong></h2>
<p>If you discover a potentially malicious repository, here are some steps you can take:</p>
<ol>
<li><p>Document some evidence.</p>
</li>
<li><p>Notify the original repository maintainers.</p>
</li>
<li><p>Report the malicious clone to GitHub.</p>
</li>
</ol>
<p>Reporting a repository or a profile on GitHub is easy and fast. Go to the user’s profile page, click “Block or report” in the left sidebar and choose “Report abuse” in the pop-up. You will have to complete a short contact form with some details about the behavior before submitting. If needed, you can find more information on <a href="https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam#reporting-a-user">GitHub</a>.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>This is a description of just one attack, from the perspective of someone who found out that one of his repository had been targeted. There are likely cases of more sophisticated attacks. But the clone repository flood we can see on GitHuB is definitely massive low quality automation. Quantity over quality.<br>To be honest, I'm quite surprised algorithms crafted at GitHub didn't manage to spot this one.</p>
<p>This also raises questions related to AI.</p>
<ul>
<li><p>What happens when LLMs are trained on malicious content? That’s a more general question about&nbsp;<a href="https://arstechnica.com/information-technology/2024/01/ai-poisoning-could-turn-open-models-into-destructive-sleeper-agents-says-anthropic/">AI poisoning</a>.</p>
</li>
<li><p>A human might easily spot the patterns and the low quality content&nbsp;<em>for now</em>. But..</p>
<ul>
<li><p>Imagine you are using coding agents, many of them. Will the agents pick-up the malicious clone instead of the original one? How to distinguish the repositories from an automaton's perspective?</p>
</li>
<li><p>The attackers <strong>will</strong> refine their tactics, making the clones more human-like and therefore luring us more easily into their traps.</p>
</li>
</ul>
</li>
<li><p>This is really a situation that makes me wonder about the early days of Google. Back then, the company had to fight huge amounts of spam due to keyword stuffing and manipulative SEO tactics. Will big tech companies have to go through a&nbsp;<a href="https://en.wikipedia.org/wiki/Timeline_of_Google_Search#Full_timeline">Florida update</a>&nbsp;moment to face the rise of AI generated spam ?</p>
</li>
</ul>
<h2 id="heading-more-resources"><strong>More Resources</strong></h2>
<ul>
<li><p><a href="https://www.trendmicro.com/en_be/research/23/j/infection-techniques-across-supply-chains-and-codebases.html">A detailed description of the attack</a></p>
</li>
<li><p><a href="https://www.cisa.gov/sites/default/files/publications/ESF_SECURING_THE_SOFTWARE_SUPPLY_CHAIN_DEVELOPERS.PDF">Complete safety recommendations</a></p>
</li>
</ul>
<p><strong>Stay Informed, Stay Secure!</strong></p>
<p>A <strong>cheat-sheet</strong> is also available on my&nbsp;<a href="https://github.com/hyperphantasia/repo-confusion-guard">GitHub</a>. Feel free to contribute to it!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
