diff --git a/docs/plans/2026-03-08-data-pipeline-design.md b/docs/plans/2026-03-08-data-pipeline-design.md new file mode 100644 index 0000000..1dac45c --- /dev/null +++ b/docs/plans/2026-03-08-data-pipeline-design.md @@ -0,0 +1,167 @@ +# Data Pipeline Example — Design + +**Date:** 2026-03-08 +**Pattern:** `trigger → data → transform → data` +**Status:** Approved + +## Goal + +Fifth runnable example for workflow-patterns. A data processing pipeline that reads structured data, applies transformations (filtering, aggregation, enrichment), and writes results. Demonstrates the `data → transform → data` pattern — read, process, write. + +## Architecture + +``` +select_pipeline() → load_data() → transform_data() → save_results() +trigger data transform data +``` + +## Module Structure + +``` +examples/data-pipeline/ +├── run.py # CLI entry point +├── .env.example # No config needed +├── .gitignore # .env, __pycache__, output/ +├── pyproject.toml # Zero runtime dependencies (stdlib only) +├── src/pipeline/ +│ ├── __init__.py +│ ├── models.py # Dataclasses: Record, Dataset, Pipeline, TransformResult +│ ├── pipelines.py # 5 pipeline presets +│ ├── reader.py # Load data from CSV/JSON (data read layer) +│ ├── transforms.py # Transform functions (filter, aggregate, enrich) +│ ├── writer.py # Save results to CSV/JSON (data write layer) +│ └── display.py # Terminal formatting +├── tests/ +│ ├── test_models.py +│ ├── test_pipelines.py +│ ├── test_reader.py +│ ├── test_transforms.py +│ ├── test_writer.py +│ ├── test_display.py +│ └── __init__.py +├── data/ # Sample input data (CSV) +│ └── sales_data.csv +└── output/ # Pipeline results +``` + +## Modules + +### models.py + +```python +@dataclass +class Record: + data: dict[str, str] + +@dataclass +class Dataset: + headers: list[str] + records: list[Record] + +@dataclass +class TransformStep: + name: str + description: str + operation: str # "filter", "aggregate", "sort", "add_column", "rename" + params: dict + +@dataclass +class Pipeline: + name: str + description: str + steps: list[TransformStep] +``` + +### pipelines.py + +5 curated pipeline presets: + +| Pipeline | Focus | +|----------|-------| +| Sales Summary | Filter by region, aggregate revenue by product | +| Top Performers | Sort by metric, take top N | +| Data Cleanup | Remove nulls, normalize formats, deduplicate | +| Period Comparison | Filter by date range, compute period-over-period | +| Custom Report | Add computed columns, rename headers, export | + +### reader.py + +- `read_csv(path)` → Dataset from CSV file +- `read_json(path)` → Dataset from JSON file +- `from_dicts(headers, records)` → Dataset from in-memory data +- `generate_sample_data()` → Realistic sample sales dataset + +### transforms.py + +- `filter_records(dataset, column, operator, value)` → filtered Dataset +- `sort_records(dataset, column, ascending)` → sorted Dataset +- `aggregate(dataset, group_by, agg_column, operation)` → aggregated Dataset +- `add_column(dataset, name, expression)` → Dataset with computed column +- `rename_columns(dataset, mapping)` → Dataset with renamed headers +- `deduplicate(dataset, columns)` → deduplicated Dataset +- `apply_pipeline(dataset, steps)` → run all steps, return TransformResult + +### writer.py + +- `write_csv(dataset, path)` → save as CSV +- `write_json(dataset, path)` → save as JSON +- `to_table_string(dataset, max_rows)` → formatted ASCII table + +### display.py + +- `format_header(title)` → boxed ASCII header +- `format_pipeline_menu(pipelines)` → numbered pipeline list +- `format_stats(dataset)` → record count, column count + +## UX Flow + +``` +$ uv run python run.py + +╔══════════════════════════════════════════╗ +║ Data Pipeline — Setup ║ +╚══════════════════════════════════════════╝ + +Choose a pipeline: + 1. Sales Summary Filter by region, aggregate revenue + 2. Top Performers Sort by metric, take top N + 3. Data Cleanup Remove nulls, normalize, deduplicate + 4. Period Comparison Filter date range, period-over-period + 5. Custom Report Computed columns, rename, export + +Pipeline (1-5): 1 + +── Sales Summary ── + +Loading data... + Loaded 50 records, 6 columns + +Applying transforms: + ✓ Filter: region = "EMEA" → 18 records + ✓ Aggregate: sum revenue by product → 5 records + +┌──────────┬─────────┐ +│ product │ revenue │ +├──────────┼─────────┤ +│ Widget A │ 45,200 │ +│ Widget B │ 32,100 │ +│ ... │ ... │ +└──────────┴─────────┘ + +Results saved to output/2026-03-08_sales-summary.csv +``` + +## Dependencies + +- Zero runtime dependencies (stdlib only) +- Uses `csv`, `json`, `pathlib` from stdlib +- `pytest` (dev) + +## Testing Strategy + +- CSV/JSON read/write round-trip tests +- Each transform function with edge cases +- Pipeline application with multiple steps +- Sample data generation +- Display formatting +- Target: ~25 tests diff --git a/examples/data-pipeline/.env.example b/examples/data-pipeline/.env.example new file mode 100644 index 0000000..52ee262 --- /dev/null +++ b/examples/data-pipeline/.env.example @@ -0,0 +1,2 @@ +# Data Pipeline — no API keys required +# All functionality works with stdlib only diff --git a/examples/data-pipeline/.gitignore b/examples/data-pipeline/.gitignore new file mode 100644 index 0000000..f66c6ec --- /dev/null +++ b/examples/data-pipeline/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +output/ +*.pyc diff --git a/examples/data-pipeline/data/sales_data.csv b/examples/data-pipeline/data/sales_data.csv new file mode 100644 index 0000000..8997911 --- /dev/null +++ b/examples/data-pipeline/data/sales_data.csv @@ -0,0 +1,121 @@ +date,region,product,units,revenue,category +2026-01-01,EMEA,Widget A,43,5160,hardware +2026-01-01,EMEA,Widget B,26,2210,hardware +2026-01-01,EMEA,Widget C,23,4600,hardware +2026-01-01,EMEA,Service X,24,8400,services +2026-01-01,EMEA,Service Y,17,2550,services +2026-01-01,APAC,Widget A,42,4536,hardware +2026-01-01,APAC,Widget B,13,994,hardware +2026-01-01,APAC,Widget C,41,7380,hardware +2026-01-01,APAC,Service X,5,1575,services +2026-01-01,APAC,Service Y,19,2565,services +2026-01-01,NA,Widget A,27,3564,hardware +2026-01-01,NA,Widget B,22,2057,hardware +2026-01-01,NA,Widget C,22,4840,hardware +2026-01-01,NA,Service X,26,10010,services +2026-01-01,NA,Service Y,28,4620,services +2026-01-01,LATAM,Widget A,19,1824,hardware +2026-01-01,LATAM,Widget B,32,2176,hardware +2026-01-01,LATAM,Widget C,36,5760,hardware +2026-01-01,LATAM,Service X,36,10080,services +2026-01-01,LATAM,Service Y,33,3960,services +2026-02-01,EMEA,Widget A,27,3240,hardware +2026-02-01,EMEA,Widget B,38,3230,hardware +2026-02-01,EMEA,Widget C,41,8200,hardware +2026-02-01,EMEA,Service X,23,8050,services +2026-02-01,EMEA,Service Y,44,6600,services +2026-02-01,APAC,Widget A,22,2376,hardware +2026-02-01,APAC,Widget B,42,3213,hardware +2026-02-01,APAC,Widget C,41,7380,hardware +2026-02-01,APAC,Service X,28,8820,services +2026-02-01,APAC,Service Y,30,4050,services +2026-02-01,NA,Widget A,39,5148,hardware +2026-02-01,NA,Widget B,8,748,hardware +2026-02-01,NA,Widget C,28,6160,hardware +2026-02-01,NA,Service X,33,12705,services +2026-02-01,NA,Service Y,26,4290,services +2026-02-01,LATAM,Widget A,44,4224,hardware +2026-02-01,LATAM,Widget B,41,2788,hardware +2026-02-01,LATAM,Widget C,10,1600,hardware +2026-02-01,LATAM,Service X,23,6440,services +2026-02-01,LATAM,Service Y,21,2520,services +2026-03-01,EMEA,Widget A,43,5160,hardware +2026-03-01,EMEA,Widget B,19,1615,hardware +2026-03-01,EMEA,Widget C,37,7400,hardware +2026-03-01,EMEA,Service X,36,12600,services +2026-03-01,EMEA,Service Y,30,4500,services +2026-03-01,APAC,Widget A,42,4536,hardware +2026-03-01,APAC,Widget B,32,2448,hardware +2026-03-01,APAC,Widget C,29,5220,hardware +2026-03-01,APAC,Service X,21,6615,services +2026-03-01,APAC,Service Y,27,3645,services +2026-03-01,NA,Widget A,17,2244,hardware +2026-03-01,NA,Widget B,19,1777,hardware +2026-03-01,NA,Widget C,35,7700,hardware +2026-03-01,NA,Service X,38,14630,services +2026-03-01,NA,Service Y,39,6435,services +2026-03-01,LATAM,Widget A,14,1344,hardware +2026-03-01,LATAM,Widget B,38,2584,hardware +2026-03-01,LATAM,Widget C,43,6880,hardware +2026-03-01,LATAM,Service X,34,9520,services +2026-03-01,LATAM,Service Y,25,3000,services +2026-04-01,EMEA,Widget A,35,4200,hardware +2026-04-01,EMEA,Widget B,13,1105,hardware +2026-04-01,EMEA,Widget C,21,4200,hardware +2026-04-01,EMEA,Service X,42,14700,services +2026-04-01,EMEA,Service Y,16,2400,services +2026-04-01,APAC,Widget A,17,1836,hardware +2026-04-01,APAC,Widget B,12,918,hardware +2026-04-01,APAC,Widget C,38,6840,hardware +2026-04-01,APAC,Service X,16,5040,services +2026-04-01,APAC,Service Y,27,3645,services +2026-04-01,NA,Widget A,20,2640,hardware +2026-04-01,NA,Widget B,42,3927,hardware +2026-04-01,NA,Widget C,39,8580,hardware +2026-04-01,NA,Service X,9,3465,services +2026-04-01,NA,Service Y,43,7095,services +2026-04-01,LATAM,Widget A,28,2688,hardware +2026-04-01,LATAM,Widget B,15,1020,hardware +2026-04-01,LATAM,Widget C,8,1280,hardware +2026-04-01,LATAM,Service X,32,8960,services +2026-04-01,LATAM,Service Y,11,1320,services +2026-05-01,EMEA,Widget A,41,4920,hardware +2026-05-01,EMEA,Widget B,22,1870,hardware +2026-05-01,EMEA,Widget C,32,6400,hardware +2026-05-01,EMEA,Service X,36,12600,services +2026-05-01,EMEA,Service Y,33,4950,services +2026-05-01,APAC,Widget A,37,3996,hardware +2026-05-01,APAC,Widget B,6,459,hardware +2026-05-01,APAC,Widget C,28,5040,hardware +2026-05-01,APAC,Service X,32,10080,services +2026-05-01,APAC,Service Y,23,3105,services +2026-05-01,NA,Widget A,9,1188,hardware +2026-05-01,NA,Widget B,34,3179,hardware +2026-05-01,NA,Widget C,41,9020,hardware +2026-05-01,NA,Service X,13,5005,services +2026-05-01,NA,Service Y,28,4620,services +2026-05-01,LATAM,Widget A,6,576,hardware +2026-05-01,LATAM,Widget B,21,1428,hardware +2026-05-01,LATAM,Widget C,30,4800,hardware +2026-05-01,LATAM,Service X,29,8120,services +2026-05-01,LATAM,Service Y,44,5280,services +2026-06-01,EMEA,Widget A,13,1560,hardware +2026-06-01,EMEA,Widget B,44,3740,hardware +2026-06-01,EMEA,Widget C,41,8200,hardware +2026-06-01,EMEA,Service X,35,12250,services +2026-06-01,EMEA,Service Y,34,5100,services +2026-06-01,APAC,Widget A,15,1620,hardware +2026-06-01,APAC,Widget B,5,382,hardware +2026-06-01,APAC,Widget C,14,2520,hardware +2026-06-01,APAC,Service X,12,3780,services +2026-06-01,APAC,Service Y,28,3780,services +2026-06-01,NA,Widget A,9,1188,hardware +2026-06-01,NA,Widget B,44,4114,hardware +2026-06-01,NA,Widget C,29,6380,hardware +2026-06-01,NA,Service X,42,16170,services +2026-06-01,NA,Service Y,44,7260,services +2026-06-01,LATAM,Widget A,41,3936,hardware +2026-06-01,LATAM,Widget B,19,1292,hardware +2026-06-01,LATAM,Widget C,37,5920,hardware +2026-06-01,LATAM,Service X,9,2520,services +2026-06-01,LATAM,Service Y,20,2400,services diff --git a/examples/data-pipeline/pyproject.toml b/examples/data-pipeline/pyproject.toml new file mode 100644 index 0000000..e1c2d09 --- /dev/null +++ b/examples/data-pipeline/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "data-pipeline" +version = "0.1.0" +description = "Data Pipeline workflow: trigger -> data -> transform -> data" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pipeline"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/examples/data-pipeline/run.py b/examples/data-pipeline/run.py new file mode 100644 index 0000000..290da7d --- /dev/null +++ b/examples/data-pipeline/run.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Data Pipeline workflow runner. + +Pattern: trigger -> data -> transform -> data + +Reads structured data, applies transformation pipelines +(filter, aggregate, sort, enrich), and writes results. + +Usage: + uv run python run.py # interactive pipeline selection + uv run python run.py --pipeline 1 # select pipeline by number + uv run python run.py --input data.csv # load from custom CSV file + uv run python run.py --format json # output as JSON instead of CSV +""" + +import argparse +import os +import sys +from pathlib import Path + +from pipeline.display import format_header, format_pipeline_menu, format_stats +from pipeline.pipelines import PIPELINES, get_pipeline +from pipeline.reader import generate_sample_data, read_csv, read_json +from pipeline.transforms import apply_pipeline +from pipeline.writer import to_table_string, write_csv, write_json + +OUTPUT_DIR = Path(__file__).parent / "output" + + +def _load_dotenv(): + """Load .env file if it exists.""" + env_path = Path(__file__).parent / ".env" + if not env_path.exists(): + return + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + if key and value: + os.environ.setdefault(key.strip(), value.strip()) + + +def _select_pipeline() -> int: + """Interactive pipeline selection. Returns 0-based index.""" + print(format_header("Data Pipeline — Setup")) + print("Choose a pipeline:") + print(format_pipeline_menu(PIPELINES)) + print() + try: + choice = input(f"Pipeline (1-{len(PIPELINES)}): ").strip() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + try: + return int(choice) - 1 + except ValueError: + return 0 + + +def main(): + _load_dotenv() + + parser = argparse.ArgumentParser( + description="Data Pipeline: trigger -> data -> transform -> data" + ) + parser.add_argument( + "--pipeline", type=int, default=None, help="Pipeline number (1-5)" + ) + parser.add_argument( + "--input", type=str, default=None, help="Path to input CSV or JSON file" + ) + parser.add_argument( + "--format", choices=["csv", "json"], default="csv", help="Output format (default: csv)" + ) + args = parser.parse_args() + + # Step 1: Trigger — select pipeline + if args.pipeline is not None: + pipeline = get_pipeline(args.pipeline - 1) + else: + pipeline = get_pipeline(_select_pipeline()) + + print(f"\n── {pipeline.name} ──\n") + + # Step 2: Data (read) — load dataset + if args.input: + input_path = Path(args.input) + if input_path.suffix == ".json": + dataset = read_json(input_path) + else: + dataset = read_csv(input_path) + if dataset.row_count == 0: + print(f" No data found in {input_path}") + sys.exit(1) + print(f" Loaded from {input_path}") + else: + dataset = generate_sample_data() + print(" Using generated sample data") + + print(format_stats(dataset)) + + # Step 3: Transform — apply pipeline steps + print(f"\n Applying {len(pipeline.steps)} step(s):\n") + result = apply_pipeline(dataset, pipeline.steps) + + for sr in result.step_results: + print(f" ✓ {sr.step_name}: {sr.input_count} → {sr.output_count} records") + + # Step 4: Data (write) — save results + print(format_header("Results")) + print(to_table_string(result.dataset)) + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + slug = pipeline.name.lower().replace(" ", "-") + if args.format == "json": + path = write_json(result.dataset, OUTPUT_DIR / f"{slug}.json") + else: + path = write_csv(result.dataset, OUTPUT_DIR / f"{slug}.csv") + + print(f"\n Results saved to {path}\n") + + +if __name__ == "__main__": + main() diff --git a/examples/data-pipeline/src/pipeline/__init__.py b/examples/data-pipeline/src/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/data-pipeline/src/pipeline/display.py b/examples/data-pipeline/src/pipeline/display.py new file mode 100644 index 0000000..845ec4c --- /dev/null +++ b/examples/data-pipeline/src/pipeline/display.py @@ -0,0 +1,25 @@ +"""Terminal display formatting for the Data Pipeline workflow.""" + +from pipeline.models import Dataset, Pipeline + + +def format_header(title: str) -> str: + """Create a boxed ASCII header.""" + width = max(len(title) + 8, 42) + top = "╔" + "═" * width + "╗" + mid = "║" + title.center(width) + "║" + bot = "╚" + "═" * width + "╝" + return f"\n{top}\n{mid}\n{bot}\n" + + +def format_pipeline_menu(pipelines: list[Pipeline]) -> str: + """Format pipeline selection menu.""" + lines = [] + for i, p in enumerate(pipelines, 1): + lines.append(f" {i}. {p.name:<22s} {p.description}") + return "\n".join(lines) + + +def format_stats(dataset: Dataset) -> str: + """Format dataset statistics.""" + return f" {dataset.row_count} records, {len(dataset.headers)} columns" diff --git a/examples/data-pipeline/src/pipeline/models.py b/examples/data-pipeline/src/pipeline/models.py new file mode 100644 index 0000000..12a837b --- /dev/null +++ b/examples/data-pipeline/src/pipeline/models.py @@ -0,0 +1,37 @@ +"""Domain models for the Data Pipeline workflow.""" + +from dataclasses import dataclass, field + + +@dataclass +class Record: + data: dict[str, str] + + +@dataclass +class Dataset: + headers: list[str] + records: list[Record] + + def column_values(self, column: str) -> list[str]: + """Get all values for a column.""" + return [r.data.get(column, "") for r in self.records] + + @property + def row_count(self) -> int: + return len(self.records) + + +@dataclass +class TransformStep: + name: str + description: str + operation: str # "filter", "aggregate", "sort", "add_column", "rename", "deduplicate" + params: dict = field(default_factory=dict) + + +@dataclass +class Pipeline: + name: str + description: str + steps: list[TransformStep] diff --git a/examples/data-pipeline/src/pipeline/pipelines.py b/examples/data-pipeline/src/pipeline/pipelines.py new file mode 100644 index 0000000..7034860 --- /dev/null +++ b/examples/data-pipeline/src/pipeline/pipelines.py @@ -0,0 +1,114 @@ +"""Pipeline presets for the Data Pipeline workflow.""" + +from pipeline.models import Pipeline, TransformStep + +PIPELINES: list[Pipeline] = [ + Pipeline( + name="Sales Summary", + description="Filter by region, aggregate revenue by product", + steps=[ + TransformStep( + name="filter_region", + description="Keep EMEA records only", + operation="filter", + params={"column": "region", "operator": "eq", "value": "EMEA"}, + ), + TransformStep( + name="aggregate_revenue", + description="Sum revenue by product", + operation="aggregate", + params={"group_by": "product", "agg_column": "revenue", "operation": "sum"}, + ), + TransformStep( + name="sort_revenue", + description="Sort by revenue descending", + operation="sort", + params={"column": "revenue", "ascending": False, "numeric": True}, + ), + ], + ), + Pipeline( + name="Top Performers", + description="Sort by revenue, show top results", + steps=[ + TransformStep( + name="sort_by_revenue", + description="Sort by revenue descending", + operation="sort", + params={"column": "revenue", "ascending": False, "numeric": True}, + ), + ], + ), + Pipeline( + name="Data Cleanup", + description="Deduplicate and normalize data", + steps=[ + TransformStep( + name="deduplicate", + description="Remove duplicate entries", + operation="deduplicate", + params={"columns": ["date", "region", "product"]}, + ), + TransformStep( + name="sort_clean", + description="Sort by date", + operation="sort", + params={"column": "date", "ascending": True}, + ), + ], + ), + Pipeline( + name="Period Comparison", + description="Filter by date range for period analysis", + steps=[ + TransformStep( + name="filter_q1", + description="Keep Q1 data (Jan-Mar)", + operation="filter", + params={"column": "date", "operator": "lt", "value": "2026-04"}, + ), + TransformStep( + name="aggregate_by_region", + description="Sum revenue by region", + operation="aggregate", + params={"group_by": "region", "agg_column": "revenue", "operation": "sum"}, + ), + TransformStep( + name="sort_regions", + description="Sort by revenue descending", + operation="sort", + params={"column": "revenue", "ascending": False, "numeric": True}, + ), + ], + ), + Pipeline( + name="Custom Report", + description="Computed columns, rename headers, export", + steps=[ + TransformStep( + name="add_unit_price", + description="Calculate revenue per unit", + operation="add_column", + params={"name": "label", "expr_cols": ["product", "region"], "expr_op": "concat", "separator": " - "}, + ), + TransformStep( + name="rename_headers", + description="Rename columns for report", + operation="rename", + params={"mapping": {"revenue": "total_revenue", "units": "quantity"}}, + ), + TransformStep( + name="sort_report", + description="Sort by total revenue", + operation="sort", + params={"column": "total_revenue", "ascending": False, "numeric": True}, + ), + ], + ), +] + + +def get_pipeline(index: int) -> Pipeline: + """Get pipeline by index, clamping to valid range.""" + clamped = max(0, min(index, len(PIPELINES) - 1)) + return PIPELINES[clamped] diff --git a/examples/data-pipeline/src/pipeline/reader.py b/examples/data-pipeline/src/pipeline/reader.py new file mode 100644 index 0000000..52d48fa --- /dev/null +++ b/examples/data-pipeline/src/pipeline/reader.py @@ -0,0 +1,74 @@ +"""Data reading for the Data Pipeline workflow (data read layer). + +Loads datasets from CSV/JSON files or generates sample data. +In a production workflow, this would connect to a database or API. +""" + +import csv +import json +from io import StringIO +from pathlib import Path + +from pipeline.models import Dataset, Record + + +def from_dicts(headers: list[str], rows: list[dict[str, str]]) -> Dataset: + """Create a Dataset from a list of dicts.""" + return Dataset( + headers=headers, + records=[Record(data=row) for row in rows], + ) + + +def read_csv(path: Path) -> Dataset: + """Read a CSV file into a Dataset. Returns empty Dataset if file not found.""" + if not path.exists(): + return Dataset(headers=[], records=[]) + text = path.read_text() + if not text.strip(): + return Dataset(headers=[], records=[]) + reader = csv.DictReader(StringIO(text)) + headers = reader.fieldnames or [] + records = [Record(data=dict(row)) for row in reader] + return Dataset(headers=list(headers), records=records) + + +def read_json(path: Path) -> Dataset: + """Read a JSON array of objects into a Dataset. Returns empty Dataset if file not found.""" + if not path.exists(): + return Dataset(headers=[], records=[]) + data = json.loads(path.read_text()) + if not data: + return Dataset(headers=[], records=[]) + headers = list(data[0].keys()) + records = [Record(data={k: str(v) for k, v in row.items()}) for row in data] + return Dataset(headers=headers, records=records) + + +def generate_sample_data() -> Dataset: + """Generate a realistic sample sales dataset.""" + regions = ["EMEA", "APAC", "NA", "LATAM"] + products = ["Widget A", "Widget B", "Widget C", "Service X", "Service Y"] + + rows: list[dict[str, str]] = [] + base_prices = {"Widget A": 120, "Widget B": 85, "Widget C": 200, "Service X": 350, "Service Y": 150} + + for month in range(1, 7): + for region in regions: + for product in products: + units = (hash(f"{month}{region}{product}") % 40) + 5 + price = base_prices[product] + # Vary by region + multiplier = {"EMEA": 1.0, "APAC": 0.9, "NA": 1.1, "LATAM": 0.8}[region] + revenue = round(units * price * multiplier) + rows.append({ + "date": f"2026-{month:02d}-01", + "region": region, + "product": product, + "units": str(units), + "revenue": str(revenue), + "category": "hardware" if product.startswith("Widget") else "services", + }) + + headers = ["date", "region", "product", "units", "revenue", "category"] + return from_dicts(headers, rows) diff --git a/examples/data-pipeline/src/pipeline/transforms.py b/examples/data-pipeline/src/pipeline/transforms.py new file mode 100644 index 0000000..656b4bd --- /dev/null +++ b/examples/data-pipeline/src/pipeline/transforms.py @@ -0,0 +1,204 @@ +"""Data transformations for the Data Pipeline workflow (transform layer). + +Pure functions that take a Dataset and return a new Dataset. +""" + +from dataclasses import dataclass + +from pipeline.models import Dataset, Record, TransformStep + + +@dataclass +class StepResult: + step_name: str + input_count: int + output_count: int + + +@dataclass +class TransformResult: + dataset: Dataset + step_results: list[StepResult] + + +def filter_records( + dataset: Dataset, column: str, operator: str, value: str +) -> Dataset: + """Filter records by column value.""" + filtered = [] + for rec in dataset.records: + cell = rec.data.get(column, "") + match operator: + case "eq": + if cell == value: + filtered.append(rec) + case "neq": + if cell != value: + filtered.append(rec) + case "gt": + try: + if float(cell) > float(value): + filtered.append(rec) + except ValueError: + pass + case "lt": + try: + if float(cell) < float(value): + filtered.append(rec) + except ValueError: + pass + case "contains": + if value in cell: + filtered.append(rec) + return Dataset(headers=dataset.headers, records=filtered) + + +def sort_records( + dataset: Dataset, column: str, ascending: bool = True, numeric: bool = False +) -> Dataset: + """Sort records by column value.""" + def key_fn(rec: Record): + val = rec.data.get(column, "") + if numeric: + try: + return float(val) + except ValueError: + return 0.0 + return val + + sorted_recs = sorted(dataset.records, key=key_fn, reverse=not ascending) + return Dataset(headers=dataset.headers, records=sorted_recs) + + +def aggregate( + dataset: Dataset, group_by: str, agg_column: str, operation: str +) -> Dataset: + """Aggregate records by grouping column.""" + groups: dict[str, list[str]] = {} + for rec in dataset.records: + key = rec.data.get(group_by, "") + groups.setdefault(key, []).append(rec.data.get(agg_column, "0")) + + records = [] + for group_key, values in groups.items(): + match operation: + case "sum": + result = str(int(sum(float(v) for v in values))) + case "count": + result = str(len(values)) + case "avg": + result = str(sum(float(v) for v in values) / len(values)) + case _: + result = str(len(values)) + records.append(Record(data={group_by: group_key, agg_column: result})) + + return Dataset(headers=[group_by, agg_column], records=records) + + +def add_column( + dataset: Dataset, + name: str, + expr_cols: list[str], + expr_op: str, + separator: str = "", +) -> Dataset: + """Add a computed column to the dataset.""" + new_records = [] + for rec in dataset.records: + new_data = dict(rec.data) + vals = [rec.data.get(c, "") for c in expr_cols] + match expr_op: + case "multiply": + try: + result = 1 + for v in vals: + result *= int(float(v)) + new_data[name] = str(result) + except ValueError: + new_data[name] = "0" + case "concat": + new_data[name] = separator.join(vals) + case _: + new_data[name] = separator.join(vals) + new_records.append(Record(data=new_data)) + + headers = dataset.headers + [name] + return Dataset(headers=headers, records=new_records) + + +def rename_columns(dataset: Dataset, mapping: dict[str, str]) -> Dataset: + """Rename columns in the dataset.""" + new_headers = [mapping.get(h, h) for h in dataset.headers] + new_records = [] + for rec in dataset.records: + new_data = {} + for key, value in rec.data.items(): + new_key = mapping.get(key, key) + new_data[new_key] = value + new_records.append(Record(data=new_data)) + return Dataset(headers=new_headers, records=new_records) + + +def deduplicate(dataset: Dataset, columns: list[str]) -> Dataset: + """Remove duplicate records based on specified columns.""" + seen: set[tuple[str, ...]] = set() + unique = [] + for rec in dataset.records: + key = tuple(rec.data.get(c, "") for c in columns) + if key not in seen: + seen.add(key) + unique.append(rec) + return Dataset(headers=dataset.headers, records=unique) + + +def apply_pipeline(dataset: Dataset, steps: list[TransformStep]) -> TransformResult: + """Apply a sequence of transform steps to a dataset.""" + current = dataset + step_results = [] + + for step in steps: + input_count = current.row_count + match step.operation: + case "filter": + current = filter_records( + current, + step.params["column"], + step.params["operator"], + step.params["value"], + ) + case "sort": + current = sort_records( + current, + step.params["column"], + step.params.get("ascending", True), + step.params.get("numeric", False), + ) + case "aggregate": + current = aggregate( + current, + step.params["group_by"], + step.params["agg_column"], + step.params["operation"], + ) + case "add_column": + current = add_column( + current, + step.params["name"], + step.params["expr_cols"], + step.params["expr_op"], + step.params.get("separator", ""), + ) + case "rename": + current = rename_columns(current, step.params["mapping"]) + case "deduplicate": + current = deduplicate(current, step.params["columns"]) + + step_results.append( + StepResult( + step_name=step.name, + input_count=input_count, + output_count=current.row_count, + ) + ) + + return TransformResult(dataset=current, step_results=step_results) diff --git a/examples/data-pipeline/src/pipeline/writer.py b/examples/data-pipeline/src/pipeline/writer.py new file mode 100644 index 0000000..eb0d33b --- /dev/null +++ b/examples/data-pipeline/src/pipeline/writer.py @@ -0,0 +1,68 @@ +"""Data writing for the Data Pipeline workflow (data write layer). + +Saves datasets to CSV/JSON files and formats ASCII tables. +""" + +import csv +import json +from io import StringIO +from pathlib import Path + +from pipeline.models import Dataset + + +def write_csv(dataset: Dataset, path: Path) -> Path: + """Write dataset to a CSV file.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=dataset.headers) + writer.writeheader() + for rec in dataset.records: + writer.writerow({h: rec.data.get(h, "") for h in dataset.headers}) + return path + + +def write_json(dataset: Dataset, path: Path) -> Path: + """Write dataset to a JSON file.""" + path.parent.mkdir(parents=True, exist_ok=True) + data = [{h: rec.data.get(h, "") for h in dataset.headers} for rec in dataset.records] + path.write_text(json.dumps(data, indent=2)) + return path + + +def to_table_string(dataset: Dataset, max_rows: int = 15) -> str: + """Format dataset as an ASCII table.""" + if not dataset.headers: + return "(empty dataset)" + + if dataset.row_count == 0: + header_line = " | ".join(dataset.headers) + return f"{header_line}\n(no records)" + + # Calculate column widths + widths = {h: len(h) for h in dataset.headers} + display_records = dataset.records[:max_rows] + for rec in display_records: + for h in dataset.headers: + widths[h] = max(widths[h], len(rec.data.get(h, ""))) + + # Build table + def row_str(values: dict[str, str]) -> str: + cells = [values.get(h, "").ljust(widths[h]) for h in dataset.headers] + return "│ " + " │ ".join(cells) + " │" + + sep_top = "┌─" + "─┬─".join("─" * widths[h] for h in dataset.headers) + "─┐" + sep_mid = "├─" + "─┼─".join("─" * widths[h] for h in dataset.headers) + "─┤" + sep_bot = "└─" + "─┴─".join("─" * widths[h] for h in dataset.headers) + "─┘" + + header_vals = {h: h for h in dataset.headers} + lines = [sep_top, row_str(header_vals), sep_mid] + for rec in display_records: + lines.append(row_str(rec.data)) + lines.append(sep_bot) + + if dataset.row_count > max_rows: + remaining = dataset.row_count - max_rows + lines.append(f"... and {remaining} more rows") + + return "\n".join(lines) diff --git a/examples/data-pipeline/tests/__init__.py b/examples/data-pipeline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/data-pipeline/tests/test_display.py b/examples/data-pipeline/tests/test_display.py new file mode 100644 index 0000000..b119105 --- /dev/null +++ b/examples/data-pipeline/tests/test_display.py @@ -0,0 +1,35 @@ +"""Tests for pipeline.display.""" + +from pipeline.display import format_header, format_pipeline_menu, format_stats +from pipeline.models import Dataset, Pipeline, Record, TransformStep + + +class TestFormatHeader: + def test_contains_title(self): + assert "Pipeline" in format_header("Pipeline") + + def test_has_box_chars(self): + assert "═" in format_header("Test") + + +class TestFormatPipelineMenu: + def test_lists_all(self): + pipes = [ + Pipeline(name="A", description="Desc A", steps=[]), + Pipeline(name="B", description="Desc B", steps=[]), + ] + result = format_pipeline_menu(pipes) + assert "1." in result + assert "2." in result + assert "A" in result + + +class TestFormatStats: + def test_shows_counts(self): + ds = Dataset( + headers=["a", "b"], + records=[Record(data={"a": "1", "b": "2"})], + ) + result = format_stats(ds) + assert "1" in result + assert "2" in result diff --git a/examples/data-pipeline/tests/test_models.py b/examples/data-pipeline/tests/test_models.py new file mode 100644 index 0000000..f3620ff --- /dev/null +++ b/examples/data-pipeline/tests/test_models.py @@ -0,0 +1,69 @@ +"""Tests for pipeline.models.""" + +from pipeline.models import Dataset, Pipeline, Record, TransformStep + + +class TestRecord: + def test_create_record(self): + rec = Record(data={"name": "Alice", "age": "30"}) + assert rec.data["name"] == "Alice" + + def test_get_value(self): + rec = Record(data={"revenue": "1000"}) + assert rec.data["revenue"] == "1000" + + +class TestDataset: + def test_create_dataset(self): + ds = Dataset( + headers=["name", "value"], + records=[Record(data={"name": "A", "value": "1"})], + ) + assert ds.headers == ["name", "value"] + assert len(ds.records) == 1 + + def test_column_values(self): + ds = Dataset( + headers=["x"], + records=[ + Record(data={"x": "a"}), + Record(data={"x": "b"}), + ], + ) + assert ds.column_values("x") == ["a", "b"] + + def test_column_values_missing_key(self): + ds = Dataset( + headers=["x"], + records=[Record(data={"x": "a"}), Record(data={})], + ) + assert ds.column_values("x") == ["a", ""] + + def test_row_count(self): + ds = Dataset(headers=["a"], records=[Record(data={"a": "1"})] * 5) + assert ds.row_count == 5 + + def test_empty_dataset(self): + ds = Dataset(headers=["a", "b"], records=[]) + assert ds.row_count == 0 + assert ds.column_values("a") == [] + + +class TestTransformStep: + def test_create_step(self): + step = TransformStep( + name="filter_region", + description="Filter by region", + operation="filter", + params={"column": "region", "operator": "eq", "value": "EMEA"}, + ) + assert step.operation == "filter" + assert step.params["column"] == "region" + + +class TestPipeline: + def test_create_pipeline(self): + step = TransformStep(name="s", description="d", operation="filter", params={}) + pipe = Pipeline(name="Test", description="A test", steps=[step]) + assert pipe.name == "Test" + assert len(pipe.steps) == 1 diff --git a/examples/data-pipeline/tests/test_pipelines.py b/examples/data-pipeline/tests/test_pipelines.py new file mode 100644 index 0000000..20c70de --- /dev/null +++ b/examples/data-pipeline/tests/test_pipelines.py @@ -0,0 +1,28 @@ +"""Tests for pipeline.pipelines.""" + +from pipeline.models import Pipeline +from pipeline.pipelines import PIPELINES, get_pipeline + + +class TestPipelines: + def test_five_pipelines(self): + assert len(PIPELINES) == 5 + + def test_all_are_pipeline_instances(self): + for p in PIPELINES: + assert isinstance(p, Pipeline) + + def test_all_have_steps(self): + for p in PIPELINES: + assert len(p.steps) >= 1, f"{p.name} has no steps" + + def test_names_unique(self): + names = [p.name for p in PIPELINES] + assert len(names) == len(set(names)) + + def test_get_pipeline_by_index(self): + assert get_pipeline(0) == PIPELINES[0] + + def test_get_pipeline_clamps(self): + assert get_pipeline(-1) == PIPELINES[0] + assert get_pipeline(99) == PIPELINES[-1] diff --git a/examples/data-pipeline/tests/test_reader.py b/examples/data-pipeline/tests/test_reader.py new file mode 100644 index 0000000..4e0f2cd --- /dev/null +++ b/examples/data-pipeline/tests/test_reader.py @@ -0,0 +1,76 @@ +"""Tests for pipeline.reader.""" + +import json + +from pipeline.models import Dataset +from pipeline.reader import ( + from_dicts, + generate_sample_data, + read_csv, + read_json, +) + + +class TestFromDicts: + def test_basic(self): + ds = from_dicts( + headers=["a", "b"], + rows=[{"a": "1", "b": "2"}, {"a": "3", "b": "4"}], + ) + assert ds.row_count == 2 + assert ds.headers == ["a", "b"] + + def test_empty(self): + ds = from_dicts(headers=["x"], rows=[]) + assert ds.row_count == 0 + + +class TestReadCsv: + def test_read_csv_file(self, tmp_path): + csv_file = tmp_path / "test.csv" + csv_file.write_text("name,value\nAlice,100\nBob,200\n") + ds = read_csv(csv_file) + assert ds.headers == ["name", "value"] + assert ds.row_count == 2 + assert ds.records[0].data["name"] == "Alice" + + def test_read_csv_missing_file(self, tmp_path): + ds = read_csv(tmp_path / "missing.csv") + assert ds.row_count == 0 + + def test_read_csv_empty_file(self, tmp_path): + csv_file = tmp_path / "empty.csv" + csv_file.write_text("") + ds = read_csv(csv_file) + assert ds.row_count == 0 + + +class TestReadJson: + def test_read_json_file(self, tmp_path): + data = [{"name": "Alice", "value": "100"}, {"name": "Bob", "value": "200"}] + json_file = tmp_path / "test.json" + json_file.write_text(json.dumps(data)) + ds = read_json(json_file) + assert ds.row_count == 2 + assert "name" in ds.headers + + def test_read_json_missing_file(self, tmp_path): + ds = read_json(tmp_path / "missing.json") + assert ds.row_count == 0 + + +class TestGenerateSampleData: + def test_generates_records(self): + ds = generate_sample_data() + assert ds.row_count >= 20 + + def test_has_expected_columns(self): + ds = generate_sample_data() + for col in ["date", "region", "product", "units", "revenue"]: + assert col in ds.headers + + def test_all_records_have_data(self): + ds = generate_sample_data() + for rec in ds.records: + assert rec.data.get("product") + assert rec.data.get("revenue") diff --git a/examples/data-pipeline/tests/test_transforms.py b/examples/data-pipeline/tests/test_transforms.py new file mode 100644 index 0000000..00680f1 --- /dev/null +++ b/examples/data-pipeline/tests/test_transforms.py @@ -0,0 +1,161 @@ +"""Tests for pipeline.transforms.""" + +from pipeline.models import Dataset, Record, TransformStep +from pipeline.transforms import ( + add_column, + aggregate, + apply_pipeline, + deduplicate, + filter_records, + rename_columns, + sort_records, +) + + +def _ds(rows: list[dict[str, str]], headers: list[str] | None = None) -> Dataset: + if headers is None: + headers = list(rows[0].keys()) if rows else [] + return Dataset(headers=headers, records=[Record(data=r) for r in rows]) + + +class TestFilterRecords: + def test_filter_eq(self): + ds = _ds([{"region": "EMEA"}, {"region": "NA"}, {"region": "EMEA"}]) + result = filter_records(ds, "region", "eq", "EMEA") + assert result.row_count == 2 + + def test_filter_neq(self): + ds = _ds([{"region": "EMEA"}, {"region": "NA"}]) + result = filter_records(ds, "region", "neq", "EMEA") + assert result.row_count == 1 + + def test_filter_gt(self): + ds = _ds([{"value": "100"}, {"value": "200"}, {"value": "50"}]) + result = filter_records(ds, "value", "gt", "99") + assert result.row_count == 2 + + def test_filter_lt(self): + ds = _ds([{"value": "100"}, {"value": "200"}, {"value": "50"}]) + result = filter_records(ds, "value", "lt", "100") + assert result.row_count == 1 + + def test_filter_contains(self): + ds = _ds([{"name": "Widget A"}, {"name": "Service X"}]) + result = filter_records(ds, "name", "contains", "Widget") + assert result.row_count == 1 + + def test_filter_empty_result(self): + ds = _ds([{"x": "a"}]) + result = filter_records(ds, "x", "eq", "b") + assert result.row_count == 0 + + +class TestSortRecords: + def test_sort_ascending(self): + ds = _ds([{"v": "30"}, {"v": "10"}, {"v": "20"}]) + result = sort_records(ds, "v", ascending=True) + assert [r.data["v"] for r in result.records] == ["10", "20", "30"] + + def test_sort_descending(self): + ds = _ds([{"v": "30"}, {"v": "10"}, {"v": "20"}]) + result = sort_records(ds, "v", ascending=False) + assert [r.data["v"] for r in result.records] == ["30", "20", "10"] + + def test_sort_numeric(self): + ds = _ds([{"v": "100"}, {"v": "20"}, {"v": "3"}]) + result = sort_records(ds, "v", ascending=True, numeric=True) + assert [r.data["v"] for r in result.records] == ["3", "20", "100"] + + +class TestAggregate: + def test_sum(self): + ds = _ds([ + {"group": "A", "val": "10"}, + {"group": "A", "val": "20"}, + {"group": "B", "val": "5"}, + ]) + result = aggregate(ds, group_by="group", agg_column="val", operation="sum") + assert result.row_count == 2 + a_row = next(r for r in result.records if r.data["group"] == "A") + assert a_row.data["val"] == "30" + + def test_count(self): + ds = _ds([ + {"group": "A", "val": "10"}, + {"group": "A", "val": "20"}, + {"group": "B", "val": "5"}, + ]) + result = aggregate(ds, group_by="group", agg_column="val", operation="count") + a_row = next(r for r in result.records if r.data["group"] == "A") + assert a_row.data["val"] == "2" + + def test_avg(self): + ds = _ds([ + {"group": "A", "val": "10"}, + {"group": "A", "val": "30"}, + ]) + result = aggregate(ds, group_by="group", agg_column="val", operation="avg") + assert result.records[0].data["val"] == "20.0" + + +class TestAddColumn: + def test_add_computed_column(self): + ds = _ds([{"units": "10", "price": "5"}], headers=["units", "price"]) + result = add_column(ds, "total", expr_cols=["units", "price"], expr_op="multiply") + assert "total" in result.headers + assert result.records[0].data["total"] == "50" + + def test_add_concat_column(self): + ds = _ds([{"a": "hello", "b": "world"}], headers=["a", "b"]) + result = add_column(ds, "combined", expr_cols=["a", "b"], expr_op="concat", separator=" ") + assert result.records[0].data["combined"] == "hello world" + + +class TestRenameColumns: + def test_rename(self): + ds = _ds([{"old_name": "val"}], headers=["old_name"]) + result = rename_columns(ds, {"old_name": "new_name"}) + assert "new_name" in result.headers + assert "old_name" not in result.headers + assert result.records[0].data["new_name"] == "val" + + +class TestDeduplicate: + def test_removes_duplicates(self): + ds = _ds([ + {"id": "1", "name": "A"}, + {"id": "1", "name": "A"}, + {"id": "2", "name": "B"}, + ]) + result = deduplicate(ds, columns=["id"]) + assert result.row_count == 2 + + def test_no_duplicates(self): + ds = _ds([{"id": "1"}, {"id": "2"}]) + result = deduplicate(ds, columns=["id"]) + assert result.row_count == 2 + + +class TestApplyPipeline: + def test_multi_step_pipeline(self): + ds = _ds([ + {"region": "EMEA", "revenue": "100"}, + {"region": "NA", "revenue": "200"}, + {"region": "EMEA", "revenue": "150"}, + ]) + steps = [ + TransformStep( + name="filter", description="EMEA only", + operation="filter", + params={"column": "region", "operator": "eq", "value": "EMEA"}, + ), + TransformStep( + name="sort", description="By revenue", + operation="sort", + params={"column": "revenue", "ascending": False, "numeric": True}, + ), + ] + result = apply_pipeline(ds, steps) + assert result.dataset.row_count == 2 + assert result.dataset.records[0].data["revenue"] == "150" + assert len(result.step_results) == 2 diff --git a/examples/data-pipeline/tests/test_writer.py b/examples/data-pipeline/tests/test_writer.py new file mode 100644 index 0000000..98d9475 --- /dev/null +++ b/examples/data-pipeline/tests/test_writer.py @@ -0,0 +1,53 @@ +"""Tests for pipeline.writer.""" + +from pipeline.models import Dataset, Record +from pipeline.writer import to_table_string, write_csv, write_json + + +def _ds(rows, headers=None): + if headers is None: + headers = list(rows[0].keys()) if rows else [] + return Dataset(headers=headers, records=[Record(data=r) for r in rows]) + + +class TestWriteCsv: + def test_write_and_read_back(self, tmp_path): + ds = _ds([{"a": "1", "b": "2"}, {"a": "3", "b": "4"}]) + path = write_csv(ds, tmp_path / "out.csv") + assert path.exists() + lines = path.read_text().strip().split("\n") + assert lines[0] == "a,b" + assert len(lines) == 3 + + def test_creates_parent_dirs(self, tmp_path): + ds = _ds([{"x": "1"}]) + path = write_csv(ds, tmp_path / "sub" / "out.csv") + assert path.exists() + + +class TestWriteJson: + def test_write_json(self, tmp_path): + ds = _ds([{"name": "Alice"}, {"name": "Bob"}]) + path = write_json(ds, tmp_path / "out.json") + assert path.exists() + import json + data = json.loads(path.read_text()) + assert len(data) == 2 + + +class TestToTableString: + def test_basic_table(self): + ds = _ds([{"name": "Alice", "age": "30"}]) + table = to_table_string(ds) + assert "name" in table + assert "Alice" in table + + def test_max_rows(self): + ds = _ds([{"v": str(i)} for i in range(20)]) + table = to_table_string(ds, max_rows=5) + assert "..." in table or "more" in table.lower() + + def test_empty_dataset(self): + ds = _ds([], headers=["a"]) + table = to_table_string(ds) + assert "empty" in table.lower() or "no" in table.lower() or table.strip() diff --git a/examples/data-pipeline/uv.lock b/examples/data-pipeline/uv.lock new file mode 100644 index 0000000..219e2bf --- /dev/null +++ b/examples/data-pipeline/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "data-pipeline" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +]