dqdetect 0.1.0__py3-none-any.whl
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.
- dqdetect/__init__.py +1 -0
- dqdetect/__main__.py +5 -0
- dqdetect/checks.py +69 -0
- dqdetect/cli.py +106 -0
- dqdetect/io.py +35 -0
- dqdetect/profiler.py +86 -0
- dqdetect/report.py +187 -0
- dqdetect-0.1.0.dist-info/METADATA +153 -0
- dqdetect-0.1.0.dist-info/RECORD +13 -0
- dqdetect-0.1.0.dist-info/WHEEL +5 -0
- dqdetect-0.1.0.dist-info/entry_points.txt +2 -0
- dqdetect-0.1.0.dist-info/licenses/LICENSE +21 -0
- dqdetect-0.1.0.dist-info/top_level.txt +1 -0
dqdetect/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
dqdetect/__main__.py
ADDED
dqdetect/checks.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from pandas.api.types import is_numeric_dtype
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
DATE_NAME_HINTS = (
|
|
6
|
+
"date",
|
|
7
|
+
"time",
|
|
8
|
+
"timestamp",
|
|
9
|
+
"created",
|
|
10
|
+
"updated",
|
|
11
|
+
"dob",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_date_like_column(column_name):
|
|
16
|
+
name = str(column_name).lower()
|
|
17
|
+
return any(hint in name for hint in DATE_NAME_HINTS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mixed_numeric_text_count(series):
|
|
21
|
+
if is_numeric_dtype(series):
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
values = series.dropna().astype(str).str.strip()
|
|
25
|
+
values = values[values.ne("")]
|
|
26
|
+
|
|
27
|
+
if values.empty:
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
parsed = pd.to_numeric(values, errors="coerce")
|
|
31
|
+
numeric_count = int(parsed.notna().sum())
|
|
32
|
+
text_count = int(parsed.isna().sum())
|
|
33
|
+
|
|
34
|
+
if numeric_count == 0 or text_count == 0:
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
return min(numeric_count, text_count)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def invalid_date_count(series, column_name):
|
|
41
|
+
if not is_date_like_column(column_name):
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
values = series.dropna()
|
|
45
|
+
if values.empty:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
parsed = pd.to_datetime(values, errors="coerce")
|
|
49
|
+
return int(parsed.isna().sum())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def iqr_outlier_count(series):
|
|
53
|
+
if not is_numeric_dtype(series):
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
values = series.dropna()
|
|
57
|
+
if len(values) < 4:
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
q1 = values.quantile(0.25)
|
|
61
|
+
q3 = values.quantile(0.75)
|
|
62
|
+
iqr = q3 - q1
|
|
63
|
+
|
|
64
|
+
if pd.isna(iqr) or iqr == 0:
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
lower = q1 - 1.5 * iqr
|
|
68
|
+
upper = q3 + 1.5 * iqr
|
|
69
|
+
return int(((values < lower) | (values > upper)).sum())
|
dqdetect/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from dqdetect.io import load_data
|
|
5
|
+
from dqdetect.profiler import profile_dataframe
|
|
6
|
+
from dqdetect.report import write_reports
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _issue_line(count, singular_subject, plural_subject, issue):
|
|
10
|
+
subject = singular_subject if count == 1 else plural_subject
|
|
11
|
+
verb = "contains" if count == 1 else "contain"
|
|
12
|
+
return f"- {count} {subject} {verb} {issue}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser():
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="dqdetect",
|
|
18
|
+
description="Profile a CSV or Excel file for common data-quality issues.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("file", help="Path to a CSV or Excel file")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--sheet",
|
|
23
|
+
help="Excel sheet name or index. Ignored for CSV files.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--output",
|
|
27
|
+
default="reports",
|
|
28
|
+
help="Directory for generated reports (default: reports)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--format",
|
|
32
|
+
choices=["md", "html", "both"],
|
|
33
|
+
default="both",
|
|
34
|
+
help="Report format (default: both)",
|
|
35
|
+
)
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv=None):
|
|
40
|
+
parser = build_parser()
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
|
|
43
|
+
source = Path(args.file)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
dataframe = load_data(source, sheet=args.sheet)
|
|
47
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
48
|
+
parser.error(str(exc))
|
|
49
|
+
|
|
50
|
+
profile = profile_dataframe(dataframe, source_name=source.name)
|
|
51
|
+
summary = profile["summary"]
|
|
52
|
+
written = write_reports(
|
|
53
|
+
profile,
|
|
54
|
+
output_dir=args.output,
|
|
55
|
+
stem=source.stem,
|
|
56
|
+
report_format=args.format,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
print("Data Quality Detective")
|
|
60
|
+
print(f"File: {source}")
|
|
61
|
+
print()
|
|
62
|
+
print(f"Rows: {summary['rows']:,}")
|
|
63
|
+
print(f"Columns: {summary['columns']:,}")
|
|
64
|
+
print(f"Duplicate rows: {summary['duplicate_rows']:,}")
|
|
65
|
+
print()
|
|
66
|
+
print("Issues found")
|
|
67
|
+
print(
|
|
68
|
+
_issue_line(
|
|
69
|
+
summary["columns_with_missing"],
|
|
70
|
+
"column",
|
|
71
|
+
"columns",
|
|
72
|
+
"missing values",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
print(
|
|
76
|
+
_issue_line(
|
|
77
|
+
summary["mixed_numeric_text_columns"],
|
|
78
|
+
"column",
|
|
79
|
+
"columns",
|
|
80
|
+
"mixed numeric/text values",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
print(
|
|
84
|
+
_issue_line(
|
|
85
|
+
summary["date_columns_with_invalid_values"],
|
|
86
|
+
"date-like column",
|
|
87
|
+
"date-like columns",
|
|
88
|
+
"invalid values",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
print(
|
|
92
|
+
_issue_line(
|
|
93
|
+
summary["numeric_columns_with_outliers"],
|
|
94
|
+
"numeric column",
|
|
95
|
+
"numeric columns",
|
|
96
|
+
"potential IQR outliers",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
print()
|
|
100
|
+
print("Reports written to:")
|
|
101
|
+
for path in written:
|
|
102
|
+
print(f" {path}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
dqdetect/io.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SUPPORTED_SUFFIXES = {".csv", ".xlsx"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_sheet(sheet):
|
|
10
|
+
if sheet is None:
|
|
11
|
+
return 0
|
|
12
|
+
|
|
13
|
+
if isinstance(sheet, str) and sheet.isdigit():
|
|
14
|
+
return int(sheet)
|
|
15
|
+
|
|
16
|
+
return sheet
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_data(path, sheet=None):
|
|
20
|
+
source = Path(path)
|
|
21
|
+
|
|
22
|
+
if not source.exists():
|
|
23
|
+
raise FileNotFoundError(f"File not found: {source}")
|
|
24
|
+
|
|
25
|
+
suffix = source.suffix.lower()
|
|
26
|
+
if suffix not in SUPPORTED_SUFFIXES:
|
|
27
|
+
supported = ", ".join(sorted(SUPPORTED_SUFFIXES))
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unsupported file type '{suffix}'. Supported types: {supported}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if suffix == ".csv":
|
|
33
|
+
return pd.read_csv(source)
|
|
34
|
+
|
|
35
|
+
return pd.read_excel(source, sheet_name=_normalize_sheet(sheet))
|
dqdetect/profiler.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from dqdetect.checks import (
|
|
6
|
+
invalid_date_count,
|
|
7
|
+
iqr_outlier_count,
|
|
8
|
+
mixed_numeric_text_count,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _sample_values(series, limit=3):
|
|
13
|
+
values = series.dropna().astype(str).drop_duplicates().head(limit)
|
|
14
|
+
return [value[:60] for value in values]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def profile_dataframe(df, source_name="dataset"):
|
|
18
|
+
row_count, column_count = df.shape
|
|
19
|
+
duplicate_rows = int(df.duplicated().sum())
|
|
20
|
+
|
|
21
|
+
columns = []
|
|
22
|
+
|
|
23
|
+
for column in df.columns:
|
|
24
|
+
series = df[column]
|
|
25
|
+
missing_count = int(series.isna().sum())
|
|
26
|
+
unique_count = int(series.nunique(dropna=True))
|
|
27
|
+
mixed_count = mixed_numeric_text_count(series)
|
|
28
|
+
invalid_dates = invalid_date_count(series, column)
|
|
29
|
+
outliers = iqr_outlier_count(series)
|
|
30
|
+
is_constant = unique_count <= 1
|
|
31
|
+
|
|
32
|
+
issues = []
|
|
33
|
+
if missing_count:
|
|
34
|
+
issues.append("missing values")
|
|
35
|
+
if is_constant:
|
|
36
|
+
issues.append("constant column")
|
|
37
|
+
if mixed_count:
|
|
38
|
+
issues.append("mixed numeric/text")
|
|
39
|
+
if invalid_dates:
|
|
40
|
+
issues.append("invalid date values")
|
|
41
|
+
if outliers:
|
|
42
|
+
issues.append("potential outliers")
|
|
43
|
+
|
|
44
|
+
columns.append(
|
|
45
|
+
{
|
|
46
|
+
"column": str(column),
|
|
47
|
+
"dtype": str(series.dtype),
|
|
48
|
+
"missing_count": missing_count,
|
|
49
|
+
"missing_pct": round(
|
|
50
|
+
(missing_count / row_count * 100) if row_count else 0.0,
|
|
51
|
+
2,
|
|
52
|
+
),
|
|
53
|
+
"unique_count": unique_count,
|
|
54
|
+
"constant": is_constant,
|
|
55
|
+
"mixed_numeric_text_count": mixed_count,
|
|
56
|
+
"invalid_date_count": invalid_dates,
|
|
57
|
+
"outlier_count": outliers,
|
|
58
|
+
"sample_values": _sample_values(series),
|
|
59
|
+
"issues": issues,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
summary = {
|
|
64
|
+
"source": Path(source_name).name,
|
|
65
|
+
"rows": row_count,
|
|
66
|
+
"columns": column_count,
|
|
67
|
+
"duplicate_rows": duplicate_rows,
|
|
68
|
+
"columns_with_missing": sum(
|
|
69
|
+
item["missing_count"] > 0 for item in columns
|
|
70
|
+
),
|
|
71
|
+
"constant_columns": sum(item["constant"] for item in columns),
|
|
72
|
+
"mixed_numeric_text_columns": sum(
|
|
73
|
+
item["mixed_numeric_text_count"] > 0 for item in columns
|
|
74
|
+
),
|
|
75
|
+
"date_columns_with_invalid_values": sum(
|
|
76
|
+
item["invalid_date_count"] > 0 for item in columns
|
|
77
|
+
),
|
|
78
|
+
"numeric_columns_with_outliers": sum(
|
|
79
|
+
item["outlier_count"] > 0 for item in columns
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"summary": summary,
|
|
85
|
+
"columns": columns,
|
|
86
|
+
}
|
dqdetect/report.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from html import escape
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _recommendations(profile):
|
|
7
|
+
summary = profile["summary"]
|
|
8
|
+
recommendations = []
|
|
9
|
+
|
|
10
|
+
if summary["duplicate_rows"]:
|
|
11
|
+
recommendations.append(
|
|
12
|
+
"Review duplicate rows before aggregating or counting records."
|
|
13
|
+
)
|
|
14
|
+
if summary["columns_with_missing"]:
|
|
15
|
+
recommendations.append(
|
|
16
|
+
"Review missing values by column before deciding whether to impute, exclude, or preserve them."
|
|
17
|
+
)
|
|
18
|
+
if summary["mixed_numeric_text_columns"]:
|
|
19
|
+
recommendations.append(
|
|
20
|
+
"Keep mixed numeric/text source values separate from any cleaned numeric field instead of coercing text to zero."
|
|
21
|
+
)
|
|
22
|
+
if summary["date_columns_with_invalid_values"]:
|
|
23
|
+
recommendations.append(
|
|
24
|
+
"Inspect invalid date values before calculating durations or time-based KPIs."
|
|
25
|
+
)
|
|
26
|
+
if summary["numeric_columns_with_outliers"]:
|
|
27
|
+
recommendations.append(
|
|
28
|
+
"Investigate outliers before removing them; unusual values may be valid business events."
|
|
29
|
+
)
|
|
30
|
+
if summary["constant_columns"]:
|
|
31
|
+
recommendations.append(
|
|
32
|
+
"Constant columns may not add analytical value and can often be excluded from modeling."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if not recommendations:
|
|
36
|
+
recommendations.append(
|
|
37
|
+
"No issues were flagged by the current rules. Domain-specific validation may still be needed."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return recommendations
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_markdown(profile):
|
|
44
|
+
summary = profile["summary"]
|
|
45
|
+
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
46
|
+
|
|
47
|
+
lines = [
|
|
48
|
+
"# Data Quality Report",
|
|
49
|
+
"",
|
|
50
|
+
f"**Source:** {summary['source']}",
|
|
51
|
+
f"**Generated:** {generated}",
|
|
52
|
+
"",
|
|
53
|
+
"## Dataset summary",
|
|
54
|
+
"",
|
|
55
|
+
"| Metric | Value |",
|
|
56
|
+
"|---|---:|",
|
|
57
|
+
f"| Rows | {summary['rows']:,} |",
|
|
58
|
+
f"| Columns | {summary['columns']:,} |",
|
|
59
|
+
f"| Duplicate rows | {summary['duplicate_rows']:,} |",
|
|
60
|
+
f"| Columns with missing values | {summary['columns_with_missing']} |",
|
|
61
|
+
f"| Constant columns | {summary['constant_columns']} |",
|
|
62
|
+
f"| Mixed numeric/text columns | {summary['mixed_numeric_text_columns']} |",
|
|
63
|
+
f"| Date columns with invalid values | {summary['date_columns_with_invalid_values']} |",
|
|
64
|
+
f"| Numeric columns with potential outliers | {summary['numeric_columns_with_outliers']} |",
|
|
65
|
+
"",
|
|
66
|
+
"## Column profile",
|
|
67
|
+
"",
|
|
68
|
+
"| Column | Type | Missing | Unique | Mixed | Invalid dates | Outliers | Issues |",
|
|
69
|
+
"|---|---|---:|---:|---:|---:|---:|---|",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for item in profile["columns"]:
|
|
73
|
+
issues = ", ".join(item["issues"]) if item["issues"] else "none"
|
|
74
|
+
lines.append(
|
|
75
|
+
"| {column} | {dtype} | {missing_count} ({missing_pct:.2f}%) | "
|
|
76
|
+
"{unique_count} | {mixed_numeric_text_count} | {invalid_date_count} | "
|
|
77
|
+
"{outlier_count} | {issues_text} |".format(**item, issues_text=issues)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
lines.extend(["", "## Recommendations", ""])
|
|
81
|
+
for recommendation in _recommendations(profile):
|
|
82
|
+
lines.append(f"- {recommendation}")
|
|
83
|
+
|
|
84
|
+
lines.extend(
|
|
85
|
+
[
|
|
86
|
+
"",
|
|
87
|
+
"## Notes",
|
|
88
|
+
"",
|
|
89
|
+
"The checks in this report are screening rules, not automatic cleaning instructions. Business context should decide what gets changed.",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_html(profile):
|
|
98
|
+
summary = profile["summary"]
|
|
99
|
+
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
100
|
+
|
|
101
|
+
rows = []
|
|
102
|
+
for item in profile["columns"]:
|
|
103
|
+
issues = ", ".join(item["issues"]) if item["issues"] else "none"
|
|
104
|
+
rows.append(
|
|
105
|
+
"<tr>"
|
|
106
|
+
f"<td>{escape(item['column'])}</td>"
|
|
107
|
+
f"<td>{escape(item['dtype'])}</td>"
|
|
108
|
+
f"<td>{item['missing_count']} ({item['missing_pct']:.2f}%)</td>"
|
|
109
|
+
f"<td>{item['unique_count']}</td>"
|
|
110
|
+
f"<td>{item['mixed_numeric_text_count']}</td>"
|
|
111
|
+
f"<td>{item['invalid_date_count']}</td>"
|
|
112
|
+
f"<td>{item['outlier_count']}</td>"
|
|
113
|
+
f"<td>{escape(issues)}</td>"
|
|
114
|
+
"</tr>"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
recommendation_items = "".join(
|
|
118
|
+
f"<li>{escape(item)}</li>" for item in _recommendations(profile)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return f"""<!doctype html>
|
|
122
|
+
<html lang="en">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="utf-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
126
|
+
<title>Data Quality Report</title>
|
|
127
|
+
<style>
|
|
128
|
+
body {{ font-family: Arial, sans-serif; max-width: 1100px; margin: 40px auto; padding: 0 24px; color: #202124; }}
|
|
129
|
+
h1, h2 {{ color: #172554; }}
|
|
130
|
+
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 24px 0; }}
|
|
131
|
+
.card {{ border: 1px solid #d9dee8; border-radius: 10px; padding: 16px; }}
|
|
132
|
+
.value {{ font-size: 1.5rem; font-weight: 700; margin-top: 6px; }}
|
|
133
|
+
table {{ border-collapse: collapse; width: 100%; font-size: 0.9rem; }}
|
|
134
|
+
th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px; text-align: left; vertical-align: top; }}
|
|
135
|
+
th {{ background: #f8fafc; }}
|
|
136
|
+
.note {{ background: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin-top: 24px; }}
|
|
137
|
+
</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<h1>Data Quality Report</h1>
|
|
141
|
+
<p><strong>Source:</strong> {escape(summary['source'])}<br>
|
|
142
|
+
<strong>Generated:</strong> {generated}</p>
|
|
143
|
+
|
|
144
|
+
<div class="cards">
|
|
145
|
+
<div class="card">Rows<div class="value">{summary['rows']:,}</div></div>
|
|
146
|
+
<div class="card">Columns<div class="value">{summary['columns']}</div></div>
|
|
147
|
+
<div class="card">Duplicate rows<div class="value">{summary['duplicate_rows']}</div></div>
|
|
148
|
+
<div class="card">Columns with missing<div class="value">{summary['columns_with_missing']}</div></div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<h2>Column profile</h2>
|
|
152
|
+
<table>
|
|
153
|
+
<thead>
|
|
154
|
+
<tr><th>Column</th><th>Type</th><th>Missing</th><th>Unique</th><th>Mixed</th><th>Invalid dates</th><th>Outliers</th><th>Issues</th></tr>
|
|
155
|
+
</thead>
|
|
156
|
+
<tbody>
|
|
157
|
+
{''.join(rows)}
|
|
158
|
+
</tbody>
|
|
159
|
+
</table>
|
|
160
|
+
|
|
161
|
+
<h2>Recommendations</h2>
|
|
162
|
+
<ul>{recommendation_items}</ul>
|
|
163
|
+
|
|
164
|
+
<div class="note">
|
|
165
|
+
These checks are screening rules, not automatic cleaning instructions. Business context should decide what gets changed.
|
|
166
|
+
</div>
|
|
167
|
+
</body>
|
|
168
|
+
</html>
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def write_reports(profile, output_dir, stem, report_format="both"):
|
|
173
|
+
output = Path(output_dir)
|
|
174
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
written = []
|
|
176
|
+
|
|
177
|
+
if report_format in {"md", "both"}:
|
|
178
|
+
markdown_path = output / f"{stem}_report.md"
|
|
179
|
+
markdown_path.write_text(render_markdown(profile), encoding="utf-8")
|
|
180
|
+
written.append(markdown_path)
|
|
181
|
+
|
|
182
|
+
if report_format in {"html", "both"}:
|
|
183
|
+
html_path = output / f"{stem}_report.html"
|
|
184
|
+
html_path.write_text(render_html(profile), encoding="utf-8")
|
|
185
|
+
written.append(html_path)
|
|
186
|
+
|
|
187
|
+
return written
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dqdetect
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line data quality profiler for CSV and Excel files
|
|
5
|
+
Author: Shahed
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Shahedr/data-quality-detective
|
|
8
|
+
Project-URL: Repository, https://github.com/Shahedr/data-quality-detective
|
|
9
|
+
Project-URL: Issues, https://github.com/Shahedr/data-quality-detective/issues
|
|
10
|
+
Keywords: data-quality,data-profiling,csv,excel,analytics,pandas
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: pandas>=2.2
|
|
25
|
+
Requires-Dist: openpyxl>=3.1
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: twine>=6.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Data Quality Detective
|
|
33
|
+
|
|
34
|
+
[](https://github.com/Shahedr/data-quality-detective/actions/workflows/tests.yml)  
|
|
35
|
+
|
|
36
|
+
A small command-line tool for quickly profiling CSV and Excel files before analysis.
|
|
37
|
+
|
|
38
|
+
I built this around a problem I keep running into in analytics work: a dataset can look usable at first, but the real issues only show up after checking missing values, duplicate rows, mixed numeric/text fields, date parsing, and unusual numeric values.
|
|
39
|
+
|
|
40
|
+
**dqdetect** puts those checks into one repeatable command and writes a report you can review or share.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
~~~bash
|
|
45
|
+
git clone https://github.com/Shahedr/data-quality-detective.git
|
|
46
|
+
cd data-quality-detective
|
|
47
|
+
|
|
48
|
+
python -m venv .venv
|
|
49
|
+
# macOS/Linux
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
# Windows
|
|
52
|
+
# .venv/Scripts/activate
|
|
53
|
+
|
|
54
|
+
pip install -e .
|
|
55
|
+
dqdetect examples/messy_orders.csv
|
|
56
|
+
~~~
|
|
57
|
+
|
|
58
|
+
By default the command writes both Markdown and HTML reports to the **reports/** folder.
|
|
59
|
+
|
|
60
|
+
## What it checks
|
|
61
|
+
|
|
62
|
+
- dataset shape and column types
|
|
63
|
+
- duplicate rows
|
|
64
|
+
- missing values by column
|
|
65
|
+
- constant columns
|
|
66
|
+
- mixed numeric/text values
|
|
67
|
+
- invalid values in date-like columns
|
|
68
|
+
- IQR-based outlier counts for numeric columns
|
|
69
|
+
|
|
70
|
+
The tool does **not** automatically delete or "fix" anything. The report is meant to help an analyst decide what deserves review.
|
|
71
|
+
|
|
72
|
+
## Example
|
|
73
|
+
|
|
74
|
+
~~~text
|
|
75
|
+
Data Quality Detective
|
|
76
|
+
File: examples/messy_orders.csv
|
|
77
|
+
|
|
78
|
+
Rows: 12
|
|
79
|
+
Columns: 8
|
|
80
|
+
Duplicate rows: 1
|
|
81
|
+
|
|
82
|
+
Reports written to:
|
|
83
|
+
reports/messy_orders_report.md
|
|
84
|
+
reports/messy_orders_report.html
|
|
85
|
+
~~~
|
|
86
|
+
|
|
87
|
+
See [examples/messy_orders_report.md](examples/messy_orders_report.md) for a sample report.
|
|
88
|
+
|
|
89
|
+
## CLI
|
|
90
|
+
|
|
91
|
+
~~~bash
|
|
92
|
+
dqdetect path/to/file.csv
|
|
93
|
+
dqdetect workbook.xlsx --sheet Orders\ndqdetect workbook.xlsx --sheet 0
|
|
94
|
+
dqdetect data.csv --format html
|
|
95
|
+
dqdetect data.csv --output audit_reports
|
|
96
|
+
~~~
|
|
97
|
+
|
|
98
|
+
Supported formats:
|
|
99
|
+
|
|
100
|
+
- CSV
|
|
101
|
+
- Excel (.xlsx)
|
|
102
|
+
|
|
103
|
+
## Why these checks?
|
|
104
|
+
|
|
105
|
+
This first version focuses on issues that can change an analysis if they are handled carelessly.
|
|
106
|
+
|
|
107
|
+
For example, a freight-cost field containing both dollar values and text such as "included elsewhere" should not simply be converted to zero. Likewise, an extreme numeric value may be a data-entry problem or a legitimate observation. The tool flags these cases instead of making the decision for you.
|
|
108
|
+
|
|
109
|
+
## Project structure
|
|
110
|
+
|
|
111
|
+
~~~text
|
|
112
|
+
data-quality-detective/
|
|
113
|
+
├── dqdetect/
|
|
114
|
+
│ ├── __init__.py
|
|
115
|
+
│ ├── checks.py
|
|
116
|
+
│ ├── cli.py
|
|
117
|
+
│ ├── io.py
|
|
118
|
+
│ ├── profiler.py
|
|
119
|
+
│ └── report.py
|
|
120
|
+
├── examples/
|
|
121
|
+
│ ├── messy_orders.csv
|
|
122
|
+
│ └── messy_orders_report.md
|
|
123
|
+
├── tests/
|
|
124
|
+
├── .github/workflows/tests.yml
|
|
125
|
+
├── CONTRIBUTING.md
|
|
126
|
+
├── LICENSE
|
|
127
|
+
└── pyproject.toml
|
|
128
|
+
~~~
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
~~~bash
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
pytest
|
|
135
|
+
~~~
|
|
136
|
+
|
|
137
|
+
## Roadmap
|
|
138
|
+
|
|
139
|
+
Things I would like to add next:
|
|
140
|
+
|
|
141
|
+
- user-configurable thresholds
|
|
142
|
+
- JSON output for automation
|
|
143
|
+
- schema rules for expected columns and types
|
|
144
|
+
- PostgreSQL table profiling
|
|
145
|
+
- comparison between two versions of a dataset
|
|
146
|
+
- richer HTML charts
|
|
147
|
+
- optional GitHub Action for automated data-quality checks
|
|
148
|
+
|
|
149
|
+
I am keeping the first release intentionally small so the checks are understandable and easy to extend.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dqdetect/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
dqdetect/__main__.py,sha256=sSpaYFy6kdpeNlcrd4cKpUPtg9H_obwQI2dBMcn0aoQ,70
|
|
3
|
+
dqdetect/checks.py,sha256=TJiRvliE4SxxxKIeYFf375mW4KEskGX4UEKLyKJfszU,1443
|
|
4
|
+
dqdetect/cli.py,sha256=7JouCNS37dYGvXnf2J6FyBCc6q0U5weTv8rAawJB-SI,2744
|
|
5
|
+
dqdetect/io.py,sha256=ZcFPmL29Edf0U1wrrJTEn_RWjr6CHhIEs7Gb4Cewhk0,782
|
|
6
|
+
dqdetect/profiler.py,sha256=yQVjzGAWEyNXia8WFgNxXppmK0RWrMxdD4xGWPUmWSM,2669
|
|
7
|
+
dqdetect/report.py,sha256=RQD6eHCOfyyrZZuVk48hCdlutqC_cGemZRyjPaKlCpQ,6845
|
|
8
|
+
dqdetect-0.1.0.dist-info/licenses/LICENSE,sha256=wji_CJxt9mbB6XDfopRCAmFqY1X-p_AKNlriT9YNy_I,1063
|
|
9
|
+
dqdetect-0.1.0.dist-info/METADATA,sha256=8ZEIBRjjdfDEKgs3lojhSYPGCo16qI4SD2jDbD8cGPk,4651
|
|
10
|
+
dqdetect-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
dqdetect-0.1.0.dist-info/entry_points.txt,sha256=ZF_jCESrxtDUUw6z7mKyVicpaANoCcCV4EF19EtyCQc,47
|
|
12
|
+
dqdetect-0.1.0.dist-info/top_level.txt,sha256=YekEoq6Y2Ho9r7uDXPXNrhJYkvmLYGmMHx5ssSg6oo0,9
|
|
13
|
+
dqdetect-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shahed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dqdetect
|