<?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[ python projects - 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[ python projects - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 27 Jul 2026 22:41:27 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/python-projects/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[ Building a Simple Web Application Security Scanner with Python: A Beginner's Guide ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you are going to learn to create a basic security tool that can be helpful in identifying common vulnerabilities in web applications. I have two goals here. The first is to empower you with the skills to develop tools that can help e... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-web-application-security-scanner-with-python/</link>
                <guid isPermaLink="false">675b035a23cb72fd28dab52d</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ python projects ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #cybersecurity ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chaitanya Rahalkar ]]>
                </dc:creator>
                <pubDate>Thu, 12 Dec 2024 15:38:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1733929791562/042042e3-56e2-4185-be19-2a0f5fa15d25.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you are going to learn to create a basic security tool that can be helpful in identifying common vulnerabilities in web applications.</p>
<p>I have two goals here. The first is to empower you with the skills to develop tools that can help enhance the overall security posture of your websites. The second is to help you practice some Python programming.</p>
<p>In this guide, you will be building a Python-based security scanner that can detect XSS, SQL injection, and sensitive PII (Personally Identifiable Information).</p>
<h3 id="heading-types-of-vulnerabilities">Types of Vulnerabilities</h3>
<p>Generally, we can categorize web security vulnerabilities into the following buckets (for even more buckets, check the <a target="_blank" href="https://owasp.org/www-project-top-ten/">OWASP Top 10</a>):</p>
<ul>
<li><p><strong>SQL injection</strong>: A technique where attackers are able to insert malicious SQL code into SQL queries through unvalidated inputs, allowing them to modify / read database contents.</p>
</li>
<li><p><strong>Cross-Site Scripting (XSS)</strong>: A technique where attackers inject malicious JavaScript in trusted websites. This allows them to execute the JavaScript code in the context of the browser and steal sensitive information or perform unauthorized operations.</p>
</li>
<li><p><strong>Sensitive information exposure</strong>: A security issue where an application unintentionally reveals sensitive data like passwords, API keys and so on through logs, insecure storage, and other vulnerabilities.</p>
</li>
<li><p><strong>Common security misconfigurations</strong>: Security issues that occurs due to improper configuration of web servers – like default credentials for administrator accounts, enabled debug mode, publicly available administrator dashboards with weak credentials, and so on.</p>
</li>
<li><p><strong>Basic authentication weaknesses</strong>: Security issues that occur due to lapses in password policies, user authentication processes, improper session management, and so on.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-our-development-environment">Setting Up Our Development Environment</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-building-our-core-scanner-class">Building our Core Scanner Class</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-implementing-the-crawler">Implementing the Crawler</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-designing-and-implementing-the-security-checks">Designing and Implementing the Security Checks</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-sql-injection-detection-check">SQL Injection Detection Check</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-xss-cross-site-scripting-check">XSS (Cross-Site Scripting) Check</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-sensitive-information-exposure-check">Sensitive Information Exposure Check</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-implementing-the-main-scanning-logic">Implementing the Main Scanning Logic</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-extending-the-security-scanner">Extending the Security Scanner</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this tutorial, you will be needing:</p>
<ul>
<li><p>Python 3.x</p>
</li>
<li><p>Basic understanding of HTTP protocols</p>
</li>
<li><p>Basic understanding of web applications</p>
</li>
<li><p>Basic understanding of how XSS, SQL injection, and basic security attacks work</p>
</li>
</ul>
<h2 id="heading-setting-up-our-development-environment">Setting Up Our Development Environment</h2>
<p>Let’s install our required dependencies with the following command:</p>
<pre><code class="lang-bash">pip install requests beautifulsoup4 urllib3 colorama
</code></pre>
<p>We’ll use these dependencies in our code file:</p>
<pre><code class="lang-python"><span class="hljs-comment"># Required packages</span>
<span class="hljs-keyword">import</span> requests
<span class="hljs-keyword">from</span> bs4 <span class="hljs-keyword">import</span> BeautifulSoup
<span class="hljs-keyword">import</span> urllib.parse
<span class="hljs-keyword">import</span> colorama
<span class="hljs-keyword">import</span> re
<span class="hljs-keyword">from</span> concurrent.futures <span class="hljs-keyword">import</span> ThreadPoolExecutor
<span class="hljs-keyword">import</span> sys
<span class="hljs-keyword">from</span> typing <span class="hljs-keyword">import</span> List, Dict, Set
</code></pre>
<h2 id="heading-building-our-core-scanner-class">Building our Core Scanner Class</h2>
<p>Once you have the dependencies, it’s time to write the core scanner class.</p>
<p>This class will serve as our main class that will handle the web security scanning functionality. It will track our visited pages and also store our findings.</p>
<p>We have the <code>normalize_url</code> function that we’ll use to ensure that you don’t rescan URLs that have already been seen before. This function will essentially remove the HTTP GET parameters from the URL. For example, <code>https://example.com/page?id=1</code> will become <code>https://example.com/page</code> after normalizing it.</p>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WebSecurityScanner</span>:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">self, target_url: str, max_depth: int = <span class="hljs-number">3</span></span>):</span>
        <span class="hljs-string">"""
        Initialize the security scanner with a target URL and maximum crawl depth.

        Args:
            target_url: The base URL to scan
            max_depth: Maximum depth for crawling links (default: 3)
        """</span>
        self.target_url = target_url
        self.max_depth = max_depth
        self.visited_urls: Set[str] = set()
        self.vulnerabilities: List[Dict] = []
        self.session = requests.Session()

        <span class="hljs-comment"># Initialize colorama for cross-platform colored output</span>
        colorama.init()

    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">normalize_url</span>(<span class="hljs-params">self, url: str</span>) -&gt; str:</span>
        <span class="hljs-string">"""Normalize the URL to prevent duplicate checks"""</span>
        parsed = urllib.parse.urlparse(url)
        <span class="hljs-keyword">return</span> <span class="hljs-string">f"<span class="hljs-subst">{parsed.scheme}</span>://<span class="hljs-subst">{parsed.netloc}</span><span class="hljs-subst">{parsed.path}</span>"</span>
</code></pre>
<h2 id="heading-implementing-the-crawler">Implementing the Crawler</h2>
<p>The first step in our scanner is to implement a web crawler that will discover pages and URLs in a given target application. Make sure you’re writing these functions in our <code>WebSecurityScanner</code> class.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">crawl</span>(<span class="hljs-params">self, url: str, depth: int = <span class="hljs-number">0</span></span>) -&gt; <span class="hljs-keyword">None</span>:</span>
    <span class="hljs-string">"""
    Crawl the website to discover pages and endpoints.

    Args:
        url: Current URL to crawl
        depth: Current depth in the crawl tree
    """</span>
    <span class="hljs-keyword">if</span> depth &gt; self.max_depth <span class="hljs-keyword">or</span> url <span class="hljs-keyword">in</span> self.visited_urls:
        <span class="hljs-keyword">return</span>

    <span class="hljs-keyword">try</span>:
        self.visited_urls.add(url)
        response = self.session.get(url, verify=<span class="hljs-literal">False</span>)
        soup = BeautifulSoup(response.text, <span class="hljs-string">'html.parser'</span>)

        <span class="hljs-comment"># Find all links in the page</span>
        links = soup.find_all(<span class="hljs-string">'a'</span>, href=<span class="hljs-literal">True</span>)
        <span class="hljs-keyword">for</span> link <span class="hljs-keyword">in</span> links:
            next_url = urllib.parse.urljoin(url, link[<span class="hljs-string">'href'</span>])
            <span class="hljs-keyword">if</span> next_url.startswith(self.target_url):
                self.crawl(next_url, depth + <span class="hljs-number">1</span>)

    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        print(<span class="hljs-string">f"Error crawling <span class="hljs-subst">{url}</span>: <span class="hljs-subst">{str(e)}</span>"</span>)
</code></pre>
<p>This <code>crawl</code> function helps us perform a depth-first crawl of a website. It will explore all pages of a website while staying within the specified domain.</p>
<p>For example, if you plan to use this scanner on <code>https://google.com</code>, the function will first get all the URLs and then one-by-one check if they belong to the specified domain (that is, <code>google.com</code>). If so, it will recursively continue to scan the seen URL up to a specified depth which is supplied with the <code>depth</code> parameter as an argument to the function. We also have some exception handling to make sure we handle errors smoothly and report any errors during crawling.</p>
<h2 id="heading-designing-and-implementing-the-security-checks">Designing and Implementing the Security Checks</h2>
<p>Now let’s finally get to the juicy part and implement our security checks. We’ll start first with SQL Injection.</p>
<h3 id="heading-sql-injection-detection-check">SQL Injection Detection Check</h3>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_sql_injection</span>(<span class="hljs-params">self, url: str</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
    <span class="hljs-string">"""Test for potential SQL injection vulnerabilities"""</span>
    sql_payloads = [<span class="hljs-string">"'"</span>, <span class="hljs-string">"1' OR '1'='1"</span>, <span class="hljs-string">"' OR 1=1--"</span>, <span class="hljs-string">"' UNION SELECT NULL--"</span>]

    <span class="hljs-keyword">for</span> payload <span class="hljs-keyword">in</span> sql_payloads:
        <span class="hljs-keyword">try</span>:
            <span class="hljs-comment"># Test GET parameters</span>
            parsed = urllib.parse.urlparse(url)
            params = urllib.parse.parse_qs(parsed.query)

            <span class="hljs-keyword">for</span> param <span class="hljs-keyword">in</span> params:
                test_url = url.replace(<span class="hljs-string">f"<span class="hljs-subst">{param}</span>=<span class="hljs-subst">{params[param][<span class="hljs-number">0</span>]}</span>"</span>, 
                                     <span class="hljs-string">f"<span class="hljs-subst">{param}</span>=<span class="hljs-subst">{payload}</span>"</span>)
                response = self.session.get(test_url)

                <span class="hljs-comment"># Look for SQL error messages</span>
                <span class="hljs-keyword">if</span> any(error <span class="hljs-keyword">in</span> response.text.lower() <span class="hljs-keyword">for</span> error <span class="hljs-keyword">in</span> 
                    [<span class="hljs-string">'sql'</span>, <span class="hljs-string">'mysql'</span>, <span class="hljs-string">'sqlite'</span>, <span class="hljs-string">'postgresql'</span>, <span class="hljs-string">'oracle'</span>]):
                    self.report_vulnerability({
                        <span class="hljs-string">'type'</span>: <span class="hljs-string">'SQL Injection'</span>,
                        <span class="hljs-string">'url'</span>: url,
                        <span class="hljs-string">'parameter'</span>: param,
                        <span class="hljs-string">'payload'</span>: payload
                    })

        <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
            print(<span class="hljs-string">f"Error testing SQL injection on <span class="hljs-subst">{url}</span>: <span class="hljs-subst">{str(e)}</span>"</span>)
</code></pre>
<p>This function essentially performs basic SQL injection checks by testing the URL against common SQL injection payloads and looking for error messages that might hint at a security vulnerability.</p>
<p>Based on the error message received after performing a simple GET request on the URL, we check whether that message is a database error or not. If it is, we use the <code>report_vulnerability</code> function to report that as a security issue in our final report that this script will generate. For the sake of this example, we are selecting a few commonly tested SQL injection payloads, but you can extend this to test even more.</p>
<h3 id="heading-xss-cross-site-scripting-check">XSS (Cross-Site Scripting) Check</h3>
<p>Now let’s implement the second security check for XSS payloads.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_xss</span>(<span class="hljs-params">self, url: str</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
    <span class="hljs-string">"""Test for potential Cross-Site Scripting vulnerabilities"""</span>
    xss_payloads = [
        <span class="hljs-string">"&lt;script&gt;alert('XSS')&lt;/script&gt;"</span>,
        <span class="hljs-string">"&lt;img src=x onerror=alert('XSS')&gt;"</span>,
        <span class="hljs-string">"javascript:alert('XSS')"</span>
    ]

    <span class="hljs-keyword">for</span> payload <span class="hljs-keyword">in</span> xss_payloads:
        <span class="hljs-keyword">try</span>:
            <span class="hljs-comment"># Test GET parameters</span>
            parsed = urllib.parse.urlparse(url)
            params = urllib.parse.parse_qs(parsed.query)

            <span class="hljs-keyword">for</span> param <span class="hljs-keyword">in</span> params:
                test_url = url.replace(<span class="hljs-string">f"<span class="hljs-subst">{param}</span>=<span class="hljs-subst">{params[param][<span class="hljs-number">0</span>]}</span>"</span>, 
                                     <span class="hljs-string">f"<span class="hljs-subst">{param}</span>=<span class="hljs-subst">{urllib.parse.quote(payload)}</span>"</span>)
                response = self.session.get(test_url)

                <span class="hljs-keyword">if</span> payload <span class="hljs-keyword">in</span> response.text:
                    self.report_vulnerability({
                        <span class="hljs-string">'type'</span>: <span class="hljs-string">'Cross-Site Scripting (XSS)'</span>,
                        <span class="hljs-string">'url'</span>: url,
                        <span class="hljs-string">'parameter'</span>: param,
                        <span class="hljs-string">'payload'</span>: payload
                    })

        <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
            print(<span class="hljs-string">f"Error testing XSS on <span class="hljs-subst">{url}</span>: <span class="hljs-subst">{str(e)}</span>"</span>)
</code></pre>
<p>This function, just like the SQL injection tester, uses a set of common XSS payloads and applies the same idea. But the key difference here is that we are looking for our injected payload to appear unmodified in our response rather than looking for an error message.</p>
<p>If you are able to see our injected payload, most likely it will be executed in the context of the victim’s browser as a reflected XSS attack.</p>
<h3 id="heading-sensitive-information-exposure-check">Sensitive Information Exposure Check</h3>
<p>Now let’s implement our final check for sensitive PII.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_sensitive_info</span>(<span class="hljs-params">self, url: str</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
    <span class="hljs-string">"""Check for exposed sensitive information"""</span>
    sensitive_patterns = {
        <span class="hljs-string">'email'</span>: <span class="hljs-string">r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'</span>,
        <span class="hljs-string">'phone'</span>: <span class="hljs-string">r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b'</span>,
        <span class="hljs-string">'ssn'</span>: <span class="hljs-string">r'\b\d{3}-\d{2}-\d{4}\b'</span>,
        <span class="hljs-string">'api_key'</span>: <span class="hljs-string">r'api[_-]?key[_-]?([\'"|`])([a-zA-Z0-9]{32,45})\1'</span>
    }

    <span class="hljs-keyword">try</span>:
        response = self.session.get(url)

        <span class="hljs-keyword">for</span> info_type, pattern <span class="hljs-keyword">in</span> sensitive_patterns.items():
            matches = re.finditer(pattern, response.text)
            <span class="hljs-keyword">for</span> match <span class="hljs-keyword">in</span> matches:
                self.report_vulnerability({
                    <span class="hljs-string">'type'</span>: <span class="hljs-string">'Sensitive Information Exposure'</span>,
                    <span class="hljs-string">'url'</span>: url,
                    <span class="hljs-string">'info_type'</span>: info_type,
                    <span class="hljs-string">'pattern'</span>: pattern
                })

    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        print(<span class="hljs-string">f"Error checking sensitive information on <span class="hljs-subst">{url}</span>: <span class="hljs-subst">{str(e)}</span>"</span>)
</code></pre>
<p>This function uses a set of predefined Regex patterns to search for PII like emails, phone numbers, SSNs, and API keys (that are prefixed with api-key-&lt;number&gt;).</p>
<p>Just like the previous two functions, we use the response text for the URL and our Regex patterns to find these PIIs in the response text. If we do find any, we report them with the <code>report_vulnerability</code> function. Make sure to have all these functions defined in the <code>WebSecurityScanner</code> class.</p>
<h2 id="heading-implementing-the-main-scanning-logic">Implementing the Main Scanning Logic</h2>
<p>Let’s finally stitch everything together by defining the <code>scan</code> and <code>report_vulnerability</code> function in the <code>WebSecurityScanner</code> class:</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">scan</span>(<span class="hljs-params">self</span>) -&gt; List[Dict]:</span>
    <span class="hljs-string">"""
    Main scanning method that coordinates the security checks

    Returns:
        List of discovered vulnerabilities
    """</span>
    print(<span class="hljs-string">f"\n<span class="hljs-subst">{colorama.Fore.BLUE}</span>Starting security scan of <span class="hljs-subst">{self.target_url}</span><span class="hljs-subst">{colorama.Style.RESET_ALL}</span>\n"</span>)

    <span class="hljs-comment"># First, crawl the website</span>
    self.crawl(self.target_url)

    <span class="hljs-comment"># Then run security checks on all discovered URLs</span>
    <span class="hljs-keyword">with</span> ThreadPoolExecutor(max_workers=<span class="hljs-number">5</span>) <span class="hljs-keyword">as</span> executor:
        <span class="hljs-keyword">for</span> url <span class="hljs-keyword">in</span> self.visited_urls:
            executor.submit(self.check_sql_injection, url)
            executor.submit(self.check_xss, url)
            executor.submit(self.check_sensitive_info, url)

    <span class="hljs-keyword">return</span> self.vulnerabilities

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">report_vulnerability</span>(<span class="hljs-params">self, vulnerability: Dict</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
    <span class="hljs-string">"""Record and display found vulnerabilities"""</span>
    self.vulnerabilities.append(vulnerability)
    print(<span class="hljs-string">f"<span class="hljs-subst">{colorama.Fore.RED}</span>[VULNERABILITY FOUND]<span class="hljs-subst">{colorama.Style.RESET_ALL}</span>"</span>)
    <span class="hljs-keyword">for</span> key, value <span class="hljs-keyword">in</span> vulnerability.items():
        print(<span class="hljs-string">f"<span class="hljs-subst">{key}</span>: <span class="hljs-subst">{value}</span>"</span>)
    print()
</code></pre>
<p>This code defines our <code>scan</code> function which will essentially invoke the <code>crawl</code> function and recursively start crawling the website. With multithreading, we will apply all three security checks on the visited URLs.</p>
<p>We have also defined the <code>report_vulnerability</code> function which will effectively print our vulnerability to the console and also store them in our <code>vulnerabilities</code> array.</p>
<p>Now let’s finally use our scanner by saving it as <code>scanner.py</code>:</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">"__main__"</span>:
    <span class="hljs-keyword">if</span> len(sys.argv) != <span class="hljs-number">2</span>:
        print(<span class="hljs-string">"Usage: python scanner.py &lt;target_url&gt;"</span>)
        sys.exit(<span class="hljs-number">1</span>)

    target_url = sys.argv[<span class="hljs-number">1</span>]
    scanner = WebSecurityScanner(target_url)
    vulnerabilities = scanner.scan()

    <span class="hljs-comment"># Print summary</span>
    print(<span class="hljs-string">f"\n<span class="hljs-subst">{colorama.Fore.GREEN}</span>Scan Complete!<span class="hljs-subst">{colorama.Style.RESET_ALL}</span>"</span>)
    print(<span class="hljs-string">f"Total URLs scanned: <span class="hljs-subst">{len(scanner.visited_urls)}</span>"</span>)
    print(<span class="hljs-string">f"Vulnerabilities found: <span class="hljs-subst">{len(vulnerabilities)}</span>"</span>)
</code></pre>
<p>The target URL will be supplied as a system argument and we will get the summary of URLs scanned and vulnerabilities found at the end of our scan. Now let’s discuss how you can extend the scanner and add more features.</p>
<h2 id="heading-extending-the-security-scanner">Extending the Security Scanner</h2>
<p>Here are some ideas to extend this basic security scanner into something even more advanced:</p>
<ol>
<li><p>Add more vulnerability checks like CSRF detection, directory traversal, and so on.</p>
</li>
<li><p>Improve reporting with an HTML or PDF output.</p>
</li>
<li><p>Add configuration options for scan intensity and scope of searching (specifying the depth of scans through a CLI argument).</p>
</li>
<li><p>Implementing proper rate limiting.</p>
</li>
<li><p>Adding authentication support for testing URLs that require session-based authentication.</p>
</li>
</ol>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Now you know how to build a basic security scanner! This scanner demonstrates a few core concepts of Web Security.</p>
<p>Keep in mind that this tutorial should only be used for educational purposes. There are several professionally designed enterprise-grade applications like Burp Suite and OWASP Zap that can check for hundreds of security vulnerabilities at a much larger scale.</p>
<p>I hope you learned the basics of web security and a bit of Python programming as well.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
