data-processing-pipeline 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data_processing_pipeline-0.1.0/PKG-INFO +5 -0
- data_processing_pipeline-0.1.0/README.md +1 -0
- data_processing_pipeline-0.1.0/data_processing_pipeline.egg-info/PKG-INFO +5 -0
- data_processing_pipeline-0.1.0/data_processing_pipeline.egg-info/SOURCES.txt +14 -0
- data_processing_pipeline-0.1.0/data_processing_pipeline.egg-info/dependency_links.txt +1 -0
- data_processing_pipeline-0.1.0/data_processing_pipeline.egg-info/top_level.txt +1 -0
- data_processing_pipeline-0.1.0/pipeline/__init__.py +0 -0
- data_processing_pipeline-0.1.0/pipeline/config.py +8 -0
- data_processing_pipeline-0.1.0/pipeline/db.py +39 -0
- data_processing_pipeline-0.1.0/pipeline/main.py +180 -0
- data_processing_pipeline-0.1.0/pipeline/monitoring.py +27 -0
- data_processing_pipeline-0.1.0/pipeline/processing.py +144 -0
- data_processing_pipeline-0.1.0/pyproject.toml +12 -0
- data_processing_pipeline-0.1.0/setup.cfg +4 -0
- data_processing_pipeline-0.1.0/tests/test_monitoring.py +10 -0
- data_processing_pipeline-0.1.0/tests/test_processing.py +32 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Data-Processing-Pipeline-Test
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
data_processing_pipeline.egg-info/PKG-INFO
|
|
4
|
+
data_processing_pipeline.egg-info/SOURCES.txt
|
|
5
|
+
data_processing_pipeline.egg-info/dependency_links.txt
|
|
6
|
+
data_processing_pipeline.egg-info/top_level.txt
|
|
7
|
+
pipeline/__init__.py
|
|
8
|
+
pipeline/config.py
|
|
9
|
+
pipeline/db.py
|
|
10
|
+
pipeline/main.py
|
|
11
|
+
pipeline/monitoring.py
|
|
12
|
+
pipeline/processing.py
|
|
13
|
+
tests/test_monitoring.py
|
|
14
|
+
tests/test_processing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pipeline
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
DB_PATH = Path(__file__).parent / "example.db"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def init_db():
|
|
8
|
+
conn = sqlite3.connect(DB_PATH)
|
|
9
|
+
cur = conn.cursor()
|
|
10
|
+
|
|
11
|
+
# Tabela zamówień
|
|
12
|
+
cur.execute(
|
|
13
|
+
"""
|
|
14
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
customer TEXT NOT NULL,
|
|
17
|
+
quantity INTEGER NOT NULL,
|
|
18
|
+
price_per_unit REAL NOT NULL
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Proste dane przykładowe
|
|
24
|
+
cur.execute("DELETE FROM orders")
|
|
25
|
+
cur.executemany(
|
|
26
|
+
"INSERT INTO orders (customer, quantity, price_per_unit) VALUES (?, ?, ?)",
|
|
27
|
+
[
|
|
28
|
+
("Alice", 10, 5.0),
|
|
29
|
+
("Bob", 3, 12.5),
|
|
30
|
+
("Charlie", 0, 7.0), # quantity 0 – przyda się do walidacji
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
conn.commit()
|
|
35
|
+
conn.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_connection():
|
|
39
|
+
return sqlite3.connect(DB_PATH)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from .config import setup_logging
|
|
5
|
+
from .db import init_db, get_connection
|
|
6
|
+
from .processing import (
|
|
7
|
+
load_orders,
|
|
8
|
+
validate_orders,
|
|
9
|
+
deduplicate_orders,
|
|
10
|
+
transform_orders,
|
|
11
|
+
save_to_csv,
|
|
12
|
+
save_to_json,
|
|
13
|
+
save_to_parquet,
|
|
14
|
+
)
|
|
15
|
+
from .monitoring import start_monitoring, end_monitoring
|
|
16
|
+
|
|
17
|
+
OUTPUT_PATH = Path(__file__).parent / "orders_processed.csv"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_pipeline(
|
|
21
|
+
output_path: str = None,
|
|
22
|
+
output_format: str = "csv",
|
|
23
|
+
dry_run: bool = False,
|
|
24
|
+
enable_dedup: bool = True,
|
|
25
|
+
):
|
|
26
|
+
"""Run the data processing pipeline."""
|
|
27
|
+
logger = setup_logging()
|
|
28
|
+
logger.info("Starting pipeline (dry_run=%s)", dry_run)
|
|
29
|
+
|
|
30
|
+
# Inicjalizacja bazy i danych
|
|
31
|
+
init_db()
|
|
32
|
+
conn = get_connection()
|
|
33
|
+
|
|
34
|
+
# Monitoring start
|
|
35
|
+
start_time = start_monitoring()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
# ETL
|
|
39
|
+
df_raw = load_orders(conn)
|
|
40
|
+
df_valid = validate_orders(df_raw)
|
|
41
|
+
|
|
42
|
+
# Deduplikacja (opcjonalna)
|
|
43
|
+
if enable_dedup:
|
|
44
|
+
df_valid = deduplicate_orders(df_valid)
|
|
45
|
+
|
|
46
|
+
df_transformed = transform_orders(df_valid)
|
|
47
|
+
|
|
48
|
+
# Zapis (jeśli nie dry-run)
|
|
49
|
+
if not dry_run:
|
|
50
|
+
output_file = output_path or str(OUTPUT_PATH)
|
|
51
|
+
|
|
52
|
+
if output_format == "csv":
|
|
53
|
+
save_to_csv(df_transformed, output_file)
|
|
54
|
+
elif output_format == "json":
|
|
55
|
+
save_to_json(df_transformed, output_file)
|
|
56
|
+
elif output_format == "parquet":
|
|
57
|
+
save_to_parquet(df_transformed, output_file)
|
|
58
|
+
else:
|
|
59
|
+
logger.error("Unknown format: %s", output_format)
|
|
60
|
+
raise ValueError(f"Unsupported format: {output_format}")
|
|
61
|
+
else:
|
|
62
|
+
logger.info("[DRY RUN] Would save %d records to %s", len(df_transformed), output_path or OUTPUT_PATH)
|
|
63
|
+
|
|
64
|
+
# Monitoring end
|
|
65
|
+
metrics = end_monitoring(
|
|
66
|
+
start_time=start_time,
|
|
67
|
+
records_in=len(df_raw),
|
|
68
|
+
records_out=len(df_transformed),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger.info(
|
|
72
|
+
"Pipeline finished in %.3f s (records in: %d, records out: %d)",
|
|
73
|
+
metrics.duration,
|
|
74
|
+
metrics.records_in,
|
|
75
|
+
metrics.records_out,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return metrics
|
|
79
|
+
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_batch_pipeline(config_file: str, dry_run: bool = False):
|
|
85
|
+
"""Run pipeline on multiple configs from JSON file."""
|
|
86
|
+
logger = setup_logging()
|
|
87
|
+
logger.info("Starting batch pipeline from config: %s", config_file)
|
|
88
|
+
|
|
89
|
+
with open(config_file, "r") as f:
|
|
90
|
+
configs = json.load(f)
|
|
91
|
+
|
|
92
|
+
results = []
|
|
93
|
+
for idx, config in enumerate(configs, 1):
|
|
94
|
+
logger.info("Processing batch %d/%d", idx, len(configs))
|
|
95
|
+
output_format = config.get("format", "csv")
|
|
96
|
+
output_path = config.get("output")
|
|
97
|
+
enable_dedup = config.get("deduplicate", True)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
metrics = run_pipeline(
|
|
101
|
+
output_path=output_path,
|
|
102
|
+
output_format=output_format,
|
|
103
|
+
dry_run=dry_run,
|
|
104
|
+
enable_dedup=enable_dedup,
|
|
105
|
+
)
|
|
106
|
+
results.append({"status": "success", "config": config, "metrics": metrics.__dict__})
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error("Batch %d failed: %s", idx, str(e))
|
|
109
|
+
results.append({"status": "error", "config": config, "error": str(e)})
|
|
110
|
+
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main():
|
|
115
|
+
"""CLI entry point for the pipeline."""
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
description="Data processing pipeline with advanced transformations"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
121
|
+
|
|
122
|
+
# Single pipeline command
|
|
123
|
+
single_parser = subparsers.add_parser("run", help="Run single pipeline")
|
|
124
|
+
single_parser.add_argument(
|
|
125
|
+
"-o", "--output",
|
|
126
|
+
type=str,
|
|
127
|
+
default=None,
|
|
128
|
+
help="Output file path (default: pipeline/orders_processed.csv)",
|
|
129
|
+
)
|
|
130
|
+
single_parser.add_argument(
|
|
131
|
+
"-f", "--format",
|
|
132
|
+
type=str,
|
|
133
|
+
choices=["csv", "json", "parquet"],
|
|
134
|
+
default="csv",
|
|
135
|
+
help="Output format (default: csv)",
|
|
136
|
+
)
|
|
137
|
+
single_parser.add_argument(
|
|
138
|
+
"--dry-run",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="Preview without saving",
|
|
141
|
+
)
|
|
142
|
+
single_parser.add_argument(
|
|
143
|
+
"--no-dedup",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="Disable deduplication",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Batch pipeline command
|
|
149
|
+
batch_parser = subparsers.add_parser("batch", help="Run batch pipeline from config")
|
|
150
|
+
batch_parser.add_argument(
|
|
151
|
+
"config",
|
|
152
|
+
type=str,
|
|
153
|
+
help="JSON config file with batch configurations",
|
|
154
|
+
)
|
|
155
|
+
batch_parser.add_argument(
|
|
156
|
+
"--dry-run",
|
|
157
|
+
action="store_true",
|
|
158
|
+
help="Preview without saving",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
args = parser.parse_args()
|
|
162
|
+
|
|
163
|
+
if args.command == "run" or args.command is None:
|
|
164
|
+
run_pipeline(
|
|
165
|
+
output_path=args.output if hasattr(args, 'output') else None,
|
|
166
|
+
output_format=args.format if hasattr(args, 'format') else "csv",
|
|
167
|
+
dry_run=args.dry_run if hasattr(args, 'dry_run') else False,
|
|
168
|
+
enable_dedup=not (args.no_dedup if hasattr(args, 'no_dedup') else False),
|
|
169
|
+
)
|
|
170
|
+
elif args.command == "batch":
|
|
171
|
+
run_batch_pipeline(
|
|
172
|
+
config_file=args.config,
|
|
173
|
+
dry_run=args.dry_run,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
parser.print_help()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class RunMetrics:
|
|
6
|
+
start_time: float
|
|
7
|
+
end_time: float
|
|
8
|
+
records_in: int
|
|
9
|
+
records_out: int
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def duration(self) -> float:
|
|
13
|
+
return self.end_time - self.start_time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def start_monitoring():
|
|
17
|
+
return time.time()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def end_monitoring(start_time: float, records_in: int, records_out: int) -> RunMetrics:
|
|
21
|
+
end_time = time.time()
|
|
22
|
+
return RunMetrics(
|
|
23
|
+
start_time=start_time,
|
|
24
|
+
end_time=end_time,
|
|
25
|
+
records_in=records_in,
|
|
26
|
+
records_out=records_out,
|
|
27
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import logging
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from time import sleep
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("data_pipeline")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def retry_on_error(max_retries: int = 3, delay: float = 1.0):
|
|
11
|
+
"""Decorator to retry a function on exception."""
|
|
12
|
+
def decorator(func: Callable):
|
|
13
|
+
@wraps(func)
|
|
14
|
+
def wrapper(*args, **kwargs):
|
|
15
|
+
for attempt in range(max_retries):
|
|
16
|
+
try:
|
|
17
|
+
return func(*args, **kwargs)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
if attempt < max_retries - 1:
|
|
20
|
+
logger.warning(
|
|
21
|
+
"Attempt %d/%d failed: %s. Retrying in %.1fs...",
|
|
22
|
+
attempt + 1, max_retries, str(e), delay
|
|
23
|
+
)
|
|
24
|
+
sleep(delay)
|
|
25
|
+
else:
|
|
26
|
+
logger.error("All %d attempts failed for %s", max_retries, func.__name__)
|
|
27
|
+
raise
|
|
28
|
+
return wrapper
|
|
29
|
+
return decorator
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_orders(conn) -> pd.DataFrame:
|
|
33
|
+
logger.info("Loading orders from database")
|
|
34
|
+
df = pd.read_sql_query("SELECT * FROM orders", conn)
|
|
35
|
+
logger.info("Loaded %d records", len(df))
|
|
36
|
+
return df
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_orders(df: pd.DataFrame) -> pd.DataFrame:
|
|
40
|
+
logger.info("Validating orders")
|
|
41
|
+
|
|
42
|
+
# Sprawdzenie braków
|
|
43
|
+
missing = df.isna().sum().sum()
|
|
44
|
+
if missing > 0:
|
|
45
|
+
logger.warning("Found %d missing values", missing)
|
|
46
|
+
|
|
47
|
+
# Sprawdzenie typów (proste)
|
|
48
|
+
assert "quantity" in df.columns
|
|
49
|
+
assert "price_per_unit" in df.columns
|
|
50
|
+
|
|
51
|
+
# Zakresy – np. quantity > 0
|
|
52
|
+
invalid_quantity = (df["quantity"] <= 0).sum()
|
|
53
|
+
if invalid_quantity > 0:
|
|
54
|
+
logger.warning("Found %d orders with non-positive quantity", invalid_quantity)
|
|
55
|
+
|
|
56
|
+
# Filtrowanie niepoprawnych rekordów
|
|
57
|
+
df_valid = df[df["quantity"] > 0].copy()
|
|
58
|
+
logger.info("After validation: %d valid records", len(df_valid))
|
|
59
|
+
return df_valid
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def deduplicate_orders(df: pd.DataFrame) -> pd.DataFrame:
|
|
63
|
+
"""Remove duplicate orders based on all columns."""
|
|
64
|
+
logger.info("Deduplicating orders")
|
|
65
|
+
initial_count = len(df)
|
|
66
|
+
|
|
67
|
+
# Usuń dokładne duplikaty
|
|
68
|
+
df_dedup = df.drop_duplicates().copy()
|
|
69
|
+
|
|
70
|
+
# Opcjonalnie: usuń duplikaty bazując na customer i quantity
|
|
71
|
+
# df_dedup = df.drop_duplicates(subset=['customer', 'quantity'], keep='first')
|
|
72
|
+
|
|
73
|
+
removed = initial_count - len(df_dedup)
|
|
74
|
+
if removed > 0:
|
|
75
|
+
logger.warning("Removed %d duplicate records", removed)
|
|
76
|
+
|
|
77
|
+
logger.info("After deduplication: %d records", len(df_dedup))
|
|
78
|
+
return df_dedup
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def transform_orders(df: pd.DataFrame) -> pd.DataFrame:
|
|
82
|
+
logger.info("Transforming orders")
|
|
83
|
+
|
|
84
|
+
# Feature engineering: total_value
|
|
85
|
+
df["total_value"] = df["quantity"] * df["price_per_unit"]
|
|
86
|
+
|
|
87
|
+
# Normalizacja – standaryzacja total_value
|
|
88
|
+
total_mean = df["total_value"].mean()
|
|
89
|
+
total_std = df["total_value"].std()
|
|
90
|
+
if total_std > 0:
|
|
91
|
+
df["total_value_norm"] = (df["total_value"] - total_mean) / total_std
|
|
92
|
+
else:
|
|
93
|
+
df["total_value_norm"] = 0
|
|
94
|
+
|
|
95
|
+
# Zaawansowane transformacje
|
|
96
|
+
# 1. Percentile bucketing
|
|
97
|
+
df["value_percentile"] = pd.qcut(df["total_value"], q=4, labels=["Q1", "Q2", "Q3", "Q4"], duplicates="drop")
|
|
98
|
+
|
|
99
|
+
# 2. Kwantyle dla ilości
|
|
100
|
+
df["quantity_category"] = pd.cut(df["quantity"], bins=[0, 5, 15, float('inf')], labels=["small", "medium", "large"], include_lowest=True)
|
|
101
|
+
|
|
102
|
+
# 3. Average price normalizacja
|
|
103
|
+
if "price_per_unit" in df.columns:
|
|
104
|
+
price_mean = df["price_per_unit"].mean()
|
|
105
|
+
df["price_norm"] = df["price_per_unit"] / price_mean if price_mean > 0 else df["price_per_unit"]
|
|
106
|
+
|
|
107
|
+
# 4. Log transform dla total_value (aby uniknąć log(0), dodajemy 1)
|
|
108
|
+
df["total_value_log"] = df["total_value"].apply(lambda x: __import__('math').log(x + 1) if x >= 0 else 0)
|
|
109
|
+
|
|
110
|
+
# 5. Customer-level aggregations (jeśli mamy customer info)
|
|
111
|
+
if "customer" in df.columns:
|
|
112
|
+
customer_stats = df.groupby("customer").agg({
|
|
113
|
+
"total_value": ["count", "sum", "mean"]
|
|
114
|
+
}).reset_index()
|
|
115
|
+
customer_stats.columns = ["customer", "customer_order_count", "customer_total_spend", "customer_avg_spend"]
|
|
116
|
+
df = df.merge(customer_stats, on="customer", how="left")
|
|
117
|
+
|
|
118
|
+
logger.info("Transformation complete with %d features", len(df.columns))
|
|
119
|
+
return df
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@retry_on_error(max_retries=3, delay=1.0)
|
|
123
|
+
def save_to_csv(df: pd.DataFrame, path: str):
|
|
124
|
+
logger.info("Saving result to %s", path)
|
|
125
|
+
df.to_csv(path, index=False)
|
|
126
|
+
logger.info("Saved %d records", len(df))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_to_json(df: pd.DataFrame, path: str):
|
|
130
|
+
"""Save dataframe to JSON format."""
|
|
131
|
+
logger.info("Saving result to JSON: %s", path)
|
|
132
|
+
df.to_json(path, orient="records", indent=2)
|
|
133
|
+
logger.info("Saved %d records to JSON", len(df))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_to_parquet(df: pd.DataFrame, path: str):
|
|
137
|
+
"""Save dataframe to Parquet format."""
|
|
138
|
+
logger.info("Saving result to Parquet: %s", path)
|
|
139
|
+
try:
|
|
140
|
+
df.to_parquet(path, index=False)
|
|
141
|
+
logger.info("Saved %d records to Parquet", len(df))
|
|
142
|
+
except ImportError:
|
|
143
|
+
logger.error("Parquet format requires 'pyarrow' or 'fastparquet' library")
|
|
144
|
+
raise
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "data-processing-pipeline"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Data processing pipeline"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name="Mikolaj Kawaler" }
|
|
11
|
+
]
|
|
12
|
+
dependencies = []
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pipeline.monitoring import start_monitoring, end_monitoring
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_monitoring_metrics():
|
|
5
|
+
start = start_monitoring()
|
|
6
|
+
metrics = end_monitoring(start, records_in=10, records_out=8)
|
|
7
|
+
|
|
8
|
+
assert metrics.records_in == 10
|
|
9
|
+
assert metrics.records_out == 8
|
|
10
|
+
assert metrics.duration >= 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from pipeline.processing import validate_orders, transform_orders
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_validate_orders_filters_non_positive_quantity():
|
|
6
|
+
df = pd.DataFrame(
|
|
7
|
+
{
|
|
8
|
+
"id": [1, 2],
|
|
9
|
+
"customer": ["A", "B"],
|
|
10
|
+
"quantity": [10, 0],
|
|
11
|
+
"price_per_unit": [5.0, 7.0],
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
df_valid = validate_orders(df)
|
|
16
|
+
assert len(df_valid) == 1
|
|
17
|
+
assert (df_valid["quantity"] > 0).all()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_transform_orders_creates_features():
|
|
21
|
+
df = pd.DataFrame(
|
|
22
|
+
{
|
|
23
|
+
"id": [1, 2],
|
|
24
|
+
"customer": ["A", "B"],
|
|
25
|
+
"quantity": [10, 3],
|
|
26
|
+
"price_per_unit": [5.0, 12.5],
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
df_transformed = transform_orders(df)
|
|
31
|
+
assert "total_value" in df_transformed.columns
|
|
32
|
+
assert "total_value_norm" in df_transformed.columns
|