AW Dev Rethought

βœ‚οΈ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-ExupΓ©ry

πŸ“Š Python Data Workflows – βš™οΈ Configuration-Driven Pipelines 🐍


Description:

As data workflows grow, hardcoded values quickly become difficult to maintain.

A script may contain file paths, validation rules, column names, thresholds, and output locations directly inside the Python code.

That works initially, but every change requires editing the script.

A configuration-driven pipeline solves this by separating what the workflow should do from how the workflow works.


What Is a Configuration-Driven Pipeline?

Instead of writing settings like this:

df = pd.read_csv(
    "sales_data.csv"
)

the pipeline reads them from a configuration file:

df = pd.read_csv(
    config["input_file"]
)

The Python logic stays unchanged while the configuration controls its behavior.


Using JSON for Configuration

JSON is a simple option for storing pipeline settings.

{
  "input_file": "sales_data.csv",
  "minimum_sales": 0,
  "group_by": "category"
}

The file can contain:

  • source paths
  • output paths
  • required columns
  • validation thresholds
  • grouping fields
  • processing options

Loading Configuration in Python

Python's built-in json module can load the settings.

with open(
    "config.json",
    "r",
    encoding="utf-8"
) as file:
    config = json.load(file)

After that, values can be accessed anywhere in the workflow.


Configurable Validation

Even validation rules can be externalised.

required_columns = set(
    config["required_columns"]
)

If another dataset requires different fields, only the JSON file needs to change.


Configurable Transformations

The same idea works for transformations.

for column in config[
    "numeric_columns"
]:
    df[column] = pd.to_numeric(
        df[column],
        errors="coerce"
    )

This makes the pipeline more flexible than hardcoding sales, quantity, or other fields throughout the script.


Why This Matters

Configuration-driven design makes workflows:

  • easier to reuse
  • easier to maintain
  • safer to change
  • simpler to deploy across environments
  • less dependent on hardcoded values

For example, development and production pipelines can use different configuration files while sharing the exact same Python code.


JSON vs YAML

The same concept can also be implemented using YAML.

JSON has the advantage of built-in Python support, while YAML is often easier for humans to read when configuration files become larger.

Regardless of the format, the important idea is keeping configuration separate from pipeline logic.


Key Takeaways

  • Configuration separates settings from code
  • JSON provides a simple way to control pipeline behaviour
  • Validation and transformation rules can also be configurable
  • Reusable workflows require fewer code changes

Code Snippet:

import json
from pathlib import Path

import pandas as pd


CONFIG_FILE = Path("config.json")


def load_config(config_file: Path) -> dict:
    """Load pipeline configuration from JSON."""
    if not config_file.exists():
        raise FileNotFoundError(f"Configuration file not found: {config_file}")

    with open(config_file, "r", encoding="utf-8") as file:
        config = json.load(file)

    print(f"βœ… Configuration loaded: {config_file.name}")
    return config


def validate_config(config: dict) -> None:
    """Validate required configuration keys."""
    required_keys = {
        "input_file",
        "output_file",
        "report_file",
        "required_columns",
        "numeric_columns",
        "drop_duplicates_on",
        "minimum_sales",
        "minimum_quantity",
        "group_by",
    }

    missing_keys = required_keys - set(config.keys())

    if missing_keys:
        raise ValueError(f"Missing configuration keys: {sorted(missing_keys)}")

    print("βœ… Configuration validated")


def extract_data(config: dict) -> pd.DataFrame:
    """Load data using the configured input path."""
    input_file = Path(config["input_file"])

    if not input_file.exists():
        raise FileNotFoundError(f"Input file not found: {input_file}")

    df = pd.read_csv(input_file)

    print(f"βœ… Loaded {len(df)} records from {input_file.name}")
    return df


def validate_data(df: pd.DataFrame, config: dict) -> None:
    """Validate dataset using configured rules."""
    required_columns = set(config["required_columns"])
    missing_columns = required_columns - set(df.columns)

    if missing_columns:
        raise ValueError(f"Missing required columns: {sorted(missing_columns)}")

    duplicate_column = config["drop_duplicates_on"]

    if duplicate_column not in df.columns:
        raise ValueError(f"Duplicate-check column '{duplicate_column}' was not found.")

    group_by = config["group_by"]

    if group_by not in df.columns:
        raise ValueError(f"Grouping column '{group_by}' was not found.")

    print("βœ… Dataset validation passed")


def transform_data(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Apply configurable cleaning rules."""
    transformed_df = df.copy()

    for column in config["numeric_columns"]:
        if column not in transformed_df.columns:
            raise ValueError(f"Numeric column '{column}' was not found.")

        transformed_df[column] = pd.to_numeric(transformed_df[column], errors="coerce")

    transformed_df = transformed_df.drop_duplicates(subset=[config["drop_duplicates_on"]], keep="first")
    transformed_df = transformed_df.dropna(subset=config["numeric_columns"])

    transformed_df = transformed_df[
        (transformed_df["sales"] >= config["minimum_sales"])
        & (transformed_df["quantity"] >= config["minimum_quantity"])
    ].copy()

    transformed_df["revenue_per_item"] = (transformed_df["sales"] / transformed_df["quantity"]).round(2)

    print(f"βœ… Transformation completed | records={len(transformed_df)}")
    return transformed_df


def generate_report(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Generate a summary using configured grouping."""
    group_by = config["group_by"]

    summary = (
        df.groupby(group_by, observed=True)
        .agg(
            total_sales=("sales", "sum"),
            average_sales=("sales", "mean"),
            total_quantity=("quantity", "sum"),
            total_orders=("order_id", "count"),
        )
        .reset_index()
        .sort_values("total_sales", ascending=False)
    )

    summary["average_sales"] = summary["average_sales"].round(2)

    print(f"βœ… Report generated | group_by={group_by}")
    return summary


def load_outputs(df: pd.DataFrame, summary: pd.DataFrame, config: dict) -> None:
    """Save configured pipeline outputs."""
    output_file = Path(config["output_file"])
    report_file = Path(config["report_file"])

    output_file.parent.mkdir(parents=True, exist_ok=True)
    report_file.parent.mkdir(parents=True, exist_ok=True)

    df.to_csv(output_file, index=False)
    summary.to_csv(report_file, index=False)

    print(f"πŸ’Ύ Processed data saved: {output_file}")
    print(f"πŸ’Ύ Summary saved: {report_file}")


def display_summary(df: pd.DataFrame, config: dict) -> None:
    """Display a concise workflow summary."""
    print("\nπŸ“Š Pipeline Summary")
    print(f"Processed records: {len(df)}")
    print(f"Total sales: {df['sales'].sum():,.2f}")
    print(f"Grouping field: {config['group_by']}")


def run_pipeline() -> None:
    """Run the configuration-driven pipeline."""
    print("πŸš€ Starting Configuration-Driven Pipeline\n")

    try:
        config = load_config(CONFIG_FILE)

        validate_config(config)

        raw_df = extract_data(config)

        validate_data(raw_df, config)

        processed_df = transform_data(raw_df, config)

        if processed_df.empty:
            raise ValueError("No valid records remain after transformation.")

        summary = generate_report(processed_df, config)

        load_outputs(processed_df, summary, config)

        display_summary(processed_df, config)

        print("\nβœ… Configuration-driven pipeline completed successfully")

    except (FileNotFoundError, ValueError, KeyError, json.JSONDecodeError, pd.errors.ParserError) as error:
        print(f"\n❌ Pipeline failed: {error}")


if __name__ == "__main__":
    run_pipeline()

Link copied!

Comments

Add Your Comment

Comment Added!