Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any | |
| import duckdb | |
| import numpy as np | |
| import pandas as pd | |
| import sqlglot | |
| from sqlglot import exp | |
| # Convert pandas/NumPy values into JSON-serializable types | |
| def _json_default(value: Any): | |
| if isinstance(value, np.integer): return int(value) | |
| if isinstance(value, np.floating): return float(value) | |
| if isinstance(value, np.bool_): return bool(value) | |
| if isinstance(value, pd.Timestamp): return value.isoformat() | |
| if pd.isna(value): return None | |
| return str(value) | |
| # Load supported data files into a pandas DataFrame | |
| def _read_dataframe(path: Path, max_rows: int | None = None) -> pd.DataFrame: | |
| suffix = path.suffix.lower() | |
| if suffix == ".csv": return pd.read_csv(path, nrows=max_rows, low_memory=False) | |
| if suffix == ".parquet": | |
| df = pd.read_parquet(path); return df.head(max_rows) if max_rows else df | |
| if suffix == ".json": | |
| try: df = pd.read_json(path) | |
| except ValueError: df = pd.read_json(path, lines=True) | |
| return df.head(max_rows) if max_rows else df | |
| if suffix == ".jsonl": | |
| df = pd.read_json(path, lines=True); return df.head(max_rows) if max_rows else df | |
| if suffix in {".xlsx", ".xls"}: | |
| df = pd.read_excel(path); return df.head(max_rows) if max_rows else df | |
| raise ValueError(f"Unsupported file type: {suffix}. Use CSV, Parquet, JSON/JSONL, XLSX, or XLS.") | |
| # Map pandas dtypes to simplified logical data types | |
| def _logical_type(series: pd.Series) -> str: | |
| if pd.api.types.is_bool_dtype(series): return "boolean" | |
| if pd.api.types.is_integer_dtype(series): return "integer" | |
| if pd.api.types.is_float_dtype(series): return "float" | |
| if pd.api.types.is_datetime64_any_dtype(series): return "datetime" | |
| if pd.api.types.is_numeric_dtype(series): return "number" | |
| return "string" | |
| # Return a small set of JSON-safe example values from a column | |
| def _safe_examples(series: pd.Series, limit: int = 3) -> list[Any]: | |
| values = series.dropna().head(limit).tolist() | |
| return [json.loads(json.dumps(v, default=_json_default)) for v in values] | |
| # Holds the loaded dataset and cached analysis results | |
| class DataContext: | |
| dataframe: pd.DataFrame | |
| source_name: str | |
| source_path: str | None = None | |
| profile_cache: dict[str, Any] | None = field(default=None, repr=False) | |
| quality_cache: dict[str, Any] | None = field(default=None, repr=False) | |
| # Create a data context directly from a supported file | |
| def from_path(cls, path: str | Path, max_rows: int | None = None) -> "DataContext": | |
| path = Path(path) | |
| return cls(_read_dataframe(path, max_rows=max_rows), path.name, str(path)) | |
| # Generate dataset-level and column-level profile information | |
| def profile(self) -> dict[str, Any]: | |
| if self.profile_cache is not None: return self.profile_cache | |
| df = self.dataframe | |
| columns = [] | |
| for column in df.columns: | |
| s = df[column]; nulls = int(s.isna().sum()); rows = int(len(df)) | |
| columns.append({ | |
| "name": str(column), "pandas_dtype": str(s.dtype), "logical_type": _logical_type(s), | |
| "nullable": bool(nulls > 0), "null_count": nulls, | |
| "null_pct": round(nulls / rows * 100, 2) if rows else 0.0, | |
| "unique_count": int(s.nunique(dropna=True)), "examples": _safe_examples(s), | |
| }) | |
| self.profile_cache = { | |
| "source": self.source_name, "rows": int(len(df)), "columns": int(len(df.columns)), | |
| "memory_mb": round(float(df.memory_usage(deep=True).sum()/1024/1024), 3), | |
| "duplicate_rows": int(df.duplicated().sum()), "column_schema": columns, | |
| } | |
| return self.profile_cache | |
| # Run built-in checks for normal data-quality issues | |
| def quality_report(self) -> dict[str, Any]: | |
| if self.quality_cache is not None: return self.quality_cache | |
| df = self.dataframe; rows = max(len(df), 1); issues = [] | |
| # Check for duplicate rows | |
| dup = int(df.duplicated().sum()) | |
| if dup: | |
| issues.append({"severity":"high" if dup/rows>=.05 else "medium","check":"duplicate_rows","column":None, | |
| "evidence":f"{dup:,} duplicate rows ({dup/rows*100:.2f}%).", | |
| "recommended_fix":"Define a business key and deduplicate with a deterministic ordering rule."}) | |
| # Run column-level quality checks | |
| for column in df.columns: | |
| s = df[column]; nulls = int(s.isna().sum()); pct = nulls/rows*100; non_null = s.dropna() | |
| if nulls: | |
| sev = "high" if pct>=20 else "medium" if pct>=5 else "low" | |
| issues.append({"severity":sev,"check":"missing_values","column":str(column),"evidence":f"{nulls:,} nulls ({pct:.2f}%).", | |
| "recommended_fix":"Confirm nullability. Impute, backfill, quarantine, or reject rows based on business meaning."}) | |
| if len(non_null) and non_null.nunique(dropna=True)==1: | |
| issues.append({"severity":"low","check":"constant_column","column":str(column),"evidence":"Only one non-null value is present.", | |
| "recommended_fix":"Remove it if it carries no signal, unless intentionally constant."}) | |
| # Additional checks for string-like columns | |
| if pd.api.types.is_object_dtype(s) or pd.api.types.is_string_dtype(s): | |
| ss = non_null.astype(str); empty = int(ss.str.strip().eq("").sum()) | |
| if empty: | |
| issues.append({"severity":"medium","check":"blank_strings","column":str(column),"evidence":f"{empty:,} blank or whitespace-only values.", | |
| "recommended_fix":"Trim strings and normalize blanks to NULL."}) | |
| types = {type(v).__name__ for v in non_null.head(500)} | |
| if len(types)>1: | |
| issues.append({"severity":"medium","check":"mixed_python_types","column":str(column),"evidence":f"Mixed value types: {sorted(types)}.", | |
| "recommended_fix":"Cast to one canonical type and quarantine failed casts."}) | |
| if len(ss): | |
| ratio = float(pd.to_numeric(ss, errors="coerce").notna().mean()) | |
| if .9 <= ratio < 1: | |
| issues.append({"severity":"medium","check":"numeric_cast_failures","column":str(column), | |
| "evidence":f"{ratio*100:.2f}% of non-null values parse as numeric.", | |
| "recommended_fix":"Normalize formatting, safe-cast, and quarantine failures."}) | |
| # Boolean columns can be reported as numeric-like by pandas, but | |
| # NumPy quantile interpolation does not support boolean subtraction. | |
| # Skip booleans for IQR-based numeric outlier detection. | |
| if pd.api.types.is_numeric_dtype(s) and not pd.api.types.is_bool_dtype(s) and len(non_null)>=8: | |
| q1,q3=float(non_null.quantile(.25)),float(non_null.quantile(.75)); iqr=q3-q1 | |
| if iqr>0: | |
| lo,hi=q1-3*iqr,q3+3*iqr; n=int(((non_null<lo)|(non_null>hi)).sum()) | |
| if n: | |
| issues.append({"severity":"low","check":"extreme_numeric_values","column":str(column), | |
| "evidence":f"{n:,} values outside 3×IQR bounds [{lo:.3g}, {hi:.3g}].", | |
| "recommended_fix":"Verify business validity before clipping or excluding."}) | |
| # Sort issues by severity before caching the report | |
| rank={"high":0,"medium":1,"low":2}; issues.sort(key=lambda x:(rank.get(x["severity"],9),x.get("column") or "")) | |
| self.quality_cache={"source":self.source_name,"issue_count":len(issues),"high":sum(i["severity"]=="high" for i in issues), | |
| "medium":sum(i["severity"]=="medium" for i in issues),"low":sum(i["severity"]=="low" for i in issues),"issues":issues[:150]} | |
| return self.quality_cache | |
| # Format the quality report as a Markdown table | |
| def quality_markdown(self) -> str: | |
| q=self.quality_report(); lines=["### Data-quality audit",f"- **Issues:** {q['issue_count']}",f"- **High:** {q['high']} | **Medium:** {q['medium']} | **Low:** {q['low']}",""] | |
| if not q["issues"]: return "\n".join(lines+["No issues were detected by the built-in checks."]) | |
| lines += ["| Severity | Check | Column | Evidence |","|---|---|---|---|"] | |
| for i in q["issues"][:30]: | |
| col=f"`{i['column']}`" if i["column"] else "dataset"; ev=i["evidence"].replace("|","\\|") | |
| lines.append(f"| **{i['severity'].upper()}** | {i['check']} | {col} | {ev} |") | |
| return "\n".join(lines) | |
| # Validate the dataset against an optional expected JSON schema | |
| def validate_schema(self, schema_json: str | None) -> dict[str, Any]: | |
| if not schema_json or not schema_json.strip(): | |
| return {"valid":True,"message":"No expected schema supplied. Inferred schema only.","errors":[],"warnings":[],"inferred_schema":self.profile()["column_schema"]} | |
| try: raw=json.loads(schema_json) | |
| except json.JSONDecodeError as exc: return {"valid":False,"message":f"Invalid schema JSON: {exc}","errors":[str(exc)],"warnings":[]} | |
| if isinstance(raw,dict) and "columns" in raw: raw=raw["columns"] | |
| # Normalize supported schema formats into a name-to-spec mapping | |
| expected={} | |
| if isinstance(raw,dict): | |
| for name,spec in raw.items(): expected[str(name)] = {"type":spec} if isinstance(spec,str) else spec if isinstance(spec,dict) else {"type":str(spec)} | |
| elif isinstance(raw,list): | |
| for item in raw: | |
| if isinstance(item,dict) and "name" in item: expected[str(item["name"])]=item | |
| else: return {"valid":False,"message":"Expected a JSON object or list describing columns.","errors":["Schema JSON must be object/list."],"warnings":[]} | |
| actual={str(c):self.dataframe[c] for c in self.dataframe.columns}; errors=[]; warnings=[] | |
| missing=sorted(set(expected)-set(actual)); unexpected=sorted(set(actual)-set(expected)) | |
| if missing: errors.append(f"Missing expected columns: {missing}") | |
| if unexpected: warnings.append(f"Unexpected columns present: {unexpected}") | |
| # Normalize common type names before comparing schemas | |
| aliases={"int":"integer","integer":"integer","long":"integer","bigint":"integer","float":"float","double":"float","decimal":"number","number":"number","numeric":"number","str":"string","string":"string","text":"string","bool":"boolean","boolean":"boolean","date":"datetime","datetime":"datetime","timestamp":"datetime"} | |
| for name,spec in expected.items(): | |
| if name not in actual: continue | |
| et=aliases.get(str(spec.get("type","")).lower(),str(spec.get("type","")).lower()); at=_logical_type(actual[name]) | |
| if not (et==at or (et=="number" and at in {"integer","float","number"}) or et==""): errors.append(f"{name}: expected type '{et}', inferred '{at}'.") | |
| if spec.get("nullable") is False and actual[name].isna().any(): errors.append(f"{name}: nullable=false but found {int(actual[name].isna().sum())} nulls.") | |
| if spec.get("unique") is True and actual[name].dropna().duplicated().any(): errors.append(f"{name}: unique=true but duplicate values were found.") | |
| return {"valid":len(errors)==0,"message":"Schema matches." if not errors else "Schema validation failed.","errors":errors,"warnings":warnings, | |
| "expected_columns":list(expected),"actual_columns":list(actual)} | |
| # Parse SQL and return a normalized form without executing it | |
| def validate_sql(self, sql: str, dialect: str = "duckdb") -> dict[str, Any]: | |
| try: | |
| parsed=sqlglot.parse_one((sql or "").strip(), read=dialect) | |
| return {"valid":True,"dialect":dialect,"normalized_sql":parsed.sql(dialect=dialect,pretty=True)} | |
| except Exception as exc: return {"valid":False,"dialect":dialect,"error":str(exc)} | |
| # Execute read-only SQL against the current DataFrame using DuckDB | |
| def execute_sql(self, sql: str, limit: int = 100) -> dict[str, Any]: | |
| sql=(sql or "").strip() | |
| try: tree=sqlglot.parse_one(sql, read="duckdb") | |
| except Exception as exc: return {"ok":False,"error":f"SQL parse failed: {exc}"} | |
| # Block statements that could modify data or database state | |
| forbidden=(exp.Insert,exp.Update,exp.Delete,exp.Create,exp.Drop,exp.Alter,exp.Command,exp.Copy) | |
| if any(tree.find(t) is not None for t in forbidden): return {"ok":False,"error":"Only read-only SELECT/CTE queries are allowed."} | |
| try: | |
| con=duckdb.connect(database=":memory:"); con.register("dataset",self.dataframe); out=con.execute(sql).df().head(limit); con.close() | |
| return {"ok":True,"rows_returned":int(len(out)),"columns":[str(c) for c in out.columns],"preview":json.loads(out.to_json(orient="records",date_format="iso"))} | |
| except Exception as exc: return {"ok":False,"error":str(exc)} | |
| # Generate a basic PySpark cleaning pipeline using the dataset columns | |
| def baseline_pyspark_pipeline(context: DataContext) -> str: | |
| cols=", ".join(repr(str(c)) for c in context.dataframe.columns[:20]) | |
| return f'''from pyspark.sql import SparkSession, functions as F | |
| spark = SparkSession.builder.appName("data-engineering-pipeline").getOrCreate() | |
| df = spark.read.option("header", True).option("inferSchema", True).csv("input.csv") | |
| source_columns = [{cols}] | |
| df = df.select(*[c for c in source_columns if c in df.columns]) | |
| for field in df.schema.fields: | |
| if field.dataType.simpleString() == "string": | |
| df = df.withColumn(field.name, F.when(F.trim(F.col(field.name)) == "", F.lit(None)).otherwise(F.trim(F.col(field.name)))) | |
| df = df.dropDuplicates() | |
| df.write.mode("overwrite").format("parquet").save("output/clean") | |
| ''' | |
| # Generate a basic SQL projection and deduplication pipeline | |
| def baseline_sql_pipeline(context: DataContext) -> str: | |
| cols=[f'"{str(c).replace(chr(34), chr(34)*2)}"' for c in context.dataframe.columns[:30]] | |
| projection=",\n ".join(cols) if cols else "*" | |
| return f'''WITH source AS ( | |
| SELECT | |
| {projection} | |
| FROM dataset | |
| ), | |
| deduplicated AS ( | |
| SELECT DISTINCT * | |
| FROM source | |
| ) | |
| SELECT * | |
| FROM deduplicated; | |
| ''' |