noweda 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.
noweda/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .io import read
2
+ from .accessor import NowEDAAccessor
3
+ __all__ = ["read"]
noweda/accessor.py ADDED
@@ -0,0 +1,31 @@
1
+ import pandas as pd
2
+ from noweda.core.engine import AutoEDAEngine
3
+ from noweda.plugins import default_plugins
4
+
5
+ @pd.api.extensions.register_dataframe_accessor("noweda")
6
+ class NowEDAAccessor:
7
+
8
+ def __init__(self, pandas_obj):
9
+ self._df = pandas_obj
10
+ self._report = None
11
+
12
+ def _ensure_analyzed(self):
13
+ if self._report is None:
14
+ engine = AutoEDAEngine(default_plugins())
15
+ self._report = engine.run_df(self._df)
16
+
17
+ def summary(self):
18
+ self._ensure_analyzed()
19
+ return self._report["results"]
20
+
21
+ def insights(self):
22
+ self._ensure_analyzed()
23
+ return self._report["insights"]
24
+
25
+ def score(self):
26
+ self._ensure_analyzed()
27
+ return self._report["scores"]
28
+
29
+ def report(self):
30
+ self._ensure_analyzed()
31
+ return self._report
noweda/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ import argparse
2
+ import json
3
+ from noweda import read
4
+ from noweda.report.html import generate_html_report
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(description="NowEDA CLI")
8
+ parser.add_argument("file", help="Path to dataset")
9
+ parser.add_argument("--html", help="Export HTML report", default=None)
10
+ parser.add_argument("--json", help="Export JSON report", default=None)
11
+
12
+ args = parser.parse_args()
13
+
14
+ df = read(args.file)
15
+ report = df.noweda.report()
16
+
17
+ print("\n=== NOWEDA INSIGHTS ===")
18
+ for insight in report["insights"]:
19
+ print(f"- {insight}")
20
+
21
+ print("\n=== SCORES ===")
22
+ print(report["scores"])
23
+
24
+ if args.json:
25
+ with open(args.json, "w") as f:
26
+ json.dump(report, f, indent=2)
27
+
28
+ if args.html:
29
+ generate_html_report(report, args.html)
File without changes
noweda/core/engine.py ADDED
@@ -0,0 +1,22 @@
1
+ from noweda.scoring.scorer import Scorer
2
+ from noweda.insights.generator import InsightGenerator
3
+
4
+ class AutoEDAEngine:
5
+
6
+ def __init__(self, plugins):
7
+ self.plugins = plugins
8
+
9
+ def run_df(self, df):
10
+ results = {}
11
+
12
+ for plugin in self.plugins:
13
+ results[plugin.name] = plugin.run(df)
14
+
15
+ scores = Scorer().compute(results)
16
+ insights = InsightGenerator().generate(results, scores)
17
+
18
+ return {
19
+ "results": results,
20
+ "scores": scores,
21
+ "insights": insights
22
+ }
File without changes
@@ -0,0 +1,151 @@
1
+ class InsightGenerator:
2
+ """Translate raw plugin results into human-readable, actionable insights."""
3
+
4
+ def generate(self, results, scores):
5
+ insights = []
6
+
7
+ self._schema_insights(results.get("schema", {}), insights)
8
+ self._missing_insights(results.get("missing", {}), insights)
9
+ self._duplicates_insights(results.get("duplicates", {}), insights)
10
+ self._stats_insights(results.get("stats", {}), insights)
11
+ self._outlier_insights(results.get("outliers", {}), insights)
12
+ self._correlation_insights(results.get("correlation", {}), insights)
13
+ self._pii_insights(results.get("pii", {}), insights)
14
+ self._encoding_insights(results.get("encoding", {}), insights)
15
+ self._score_insights(scores, insights)
16
+
17
+ return insights
18
+
19
+ # ------------------------------------------------------------------
20
+ # Per-plugin insight rules
21
+ # ------------------------------------------------------------------
22
+
23
+ def _schema_insights(self, schema, insights):
24
+ id_cols = [c for c, v in schema.items() if v.get("role") == "id_candidate"]
25
+ cat_cols = [c for c, v in schema.items() if "categorical" in v.get("role", "")]
26
+ datetime_cols = [c for c, v in schema.items() if v.get("role") == "datetime"]
27
+
28
+ if id_cols:
29
+ insights.append(
30
+ f"Likely identifier column(s) detected: {', '.join(id_cols)}. "
31
+ "Consider excluding from modelling."
32
+ )
33
+ if cat_cols:
34
+ insights.append(
35
+ f"Column(s) with low cardinality (likely categorical): {', '.join(cat_cols)}."
36
+ )
37
+ if datetime_cols:
38
+ insights.append(
39
+ f"Datetime column(s) detected: {', '.join(datetime_cols)}. "
40
+ "Temporal features may be valuable."
41
+ )
42
+
43
+ def _missing_insights(self, missing, insights):
44
+ critical = [(c, v) for c, v in missing.items() if v > 0.5]
45
+ high = [(c, v) for c, v in missing.items() if 0.3 < v <= 0.5]
46
+ low = [(c, v) for c, v in missing.items() if 0 < v <= 0.3]
47
+
48
+ for col, pct in critical:
49
+ insights.append(
50
+ f"Column '{col}' is {pct:.0%} missing — consider dropping it."
51
+ )
52
+ for col, pct in high:
53
+ insights.append(
54
+ f"Column '{col}' has high missing rate ({pct:.0%}) — imputation recommended."
55
+ )
56
+ if low:
57
+ cols = ", ".join(f"'{c}'" for c, _ in low)
58
+ insights.append(f"Minor missing values in: {cols}.")
59
+
60
+ def _duplicates_insights(self, duplicates, insights):
61
+ dup_rows = duplicates.get("duplicate_rows", 0)
62
+ dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
63
+ const_cols = duplicates.get("constant_columns", [])
64
+
65
+ if dup_rows > 0:
66
+ insights.append(
67
+ f"{dup_rows} duplicate row(s) detected ({dup_pct:.1%} of data). "
68
+ "De-duplication is recommended."
69
+ )
70
+ if const_cols:
71
+ insights.append(
72
+ f"Constant (zero-variance) column(s) detected: {', '.join(const_cols)}. "
73
+ "These carry no information and can be dropped."
74
+ )
75
+
76
+ def _stats_insights(self, stats, insights):
77
+ for col, s in stats.items():
78
+ skew = s.get("skewness")
79
+ if skew is not None and abs(skew) > 2:
80
+ direction = "right" if skew > 0 else "left"
81
+ insights.append(
82
+ f"Column '{col}' is heavily {direction}-skewed (skewness={skew:.2f}). "
83
+ "Log or power transform may help."
84
+ )
85
+
86
+ def _outlier_insights(self, outliers, insights):
87
+ for col, count in outliers.items():
88
+ if count > 0:
89
+ insights.append(
90
+ f"Column '{col}' has {count} outlier(s) (IQR method). "
91
+ "Review before modelling."
92
+ )
93
+
94
+ def _correlation_insights(self, correlation, insights):
95
+ seen = set()
96
+ for col1, corrs in correlation.items():
97
+ for col2, val in corrs.items():
98
+ pair = tuple(sorted([col1, col2]))
99
+ if col1 == col2 or pair in seen:
100
+ continue
101
+ seen.add(pair)
102
+ if abs(val) > 0.9:
103
+ insights.append(
104
+ f"Very strong correlation ({val:.2f}) between '{col1}' and '{col2}'. "
105
+ "One may be redundant."
106
+ )
107
+ elif abs(val) > 0.7:
108
+ insights.append(
109
+ f"Strong correlation ({val:.2f}) between '{col1}' and '{col2}'."
110
+ )
111
+
112
+ def _pii_insights(self, pii, insights):
113
+ for col, info in pii.items():
114
+ count = info.get("emails_detected", 0)
115
+ insights.append(
116
+ f"PII detected in column '{col}': {count} email address(es) found. "
117
+ "Mask or remove before sharing."
118
+ )
119
+
120
+ def _encoding_insights(self, encoding, insights):
121
+ for col, enc_type in encoding.items():
122
+ insights.append(
123
+ f"Column '{col}' may contain encoded data ({enc_type}). "
124
+ "Inspect for hidden payloads or obfuscation."
125
+ )
126
+
127
+ def _score_insights(self, scores, insights):
128
+ quality = scores.get("data_quality", 100)
129
+ risk = scores.get("risk", 0)
130
+
131
+ if quality >= 90:
132
+ insights.append("Data quality score is excellent (>=90). Dataset looks clean.")
133
+ elif quality >= 70:
134
+ insights.append(f"Data quality score is acceptable ({quality}). Minor issues present.")
135
+ else:
136
+ insights.append(
137
+ f"Data quality score is low ({quality}). Significant cleaning required."
138
+ )
139
+
140
+ if risk == 0:
141
+ insights.append("No risk signals detected.")
142
+ elif risk <= 20:
143
+ insights.append(f"Low risk level ({risk}). Some sensitive indicators found.")
144
+ elif risk <= 50:
145
+ insights.append(
146
+ f"Moderate risk level ({risk}). Review PII and encoded columns before sharing."
147
+ )
148
+ else:
149
+ insights.append(
150
+ f"High risk level ({risk}). Sensitive data likely present — handle with care."
151
+ )
noweda/io.py ADDED
@@ -0,0 +1,15 @@
1
+ import pandas as pd
2
+
3
+ def read(file_path, **kwargs):
4
+ if file_path.endswith(".csv"):
5
+ return pd.read_csv(file_path, **kwargs)
6
+ elif file_path.endswith(".xlsx"):
7
+ return pd.read_excel(file_path, **kwargs)
8
+ elif file_path.endswith(".json"):
9
+ return pd.read_json(file_path, **kwargs)
10
+ elif file_path.endswith(".xml"):
11
+ return pd.read_xml(file_path, **kwargs)
12
+ elif file_path.endswith(".html"):
13
+ return pd.read_html(file_path, **kwargs)[0]
14
+ else:
15
+ raise ValueError(f"Unsupported file type: {file_path}")
@@ -0,0 +1,21 @@
1
+ from .missing import MissingDataPlugin
2
+ from .stats import StatsPlugin
3
+ from .schema import SchemaPlugin
4
+ from .duplicates import DuplicatesPlugin
5
+ from .correlation import CorrelationPlugin
6
+ from .outliers import OutlierPlugin
7
+ from .pii import PIIDetectorPlugin
8
+ from .encoding import EncodingDetectionPlugin
9
+
10
+
11
+ def default_plugins():
12
+ return [
13
+ SchemaPlugin(),
14
+ StatsPlugin(),
15
+ MissingDataPlugin(),
16
+ DuplicatesPlugin(),
17
+ CorrelationPlugin(),
18
+ OutlierPlugin(),
19
+ PIIDetectorPlugin(),
20
+ EncodingDetectionPlugin(),
21
+ ]
noweda/plugins/base.py ADDED
@@ -0,0 +1,5 @@
1
+ class BasePlugin:
2
+ name = "base"
3
+
4
+ def run(self, df):
5
+ raise NotImplementedError
@@ -0,0 +1,10 @@
1
+ from .base import BasePlugin
2
+
3
+ class CorrelationPlugin(BasePlugin):
4
+ name = "correlation"
5
+
6
+ def run(self, df):
7
+ numeric_df = df.select_dtypes(include="number")
8
+ if numeric_df.empty:
9
+ return {}
10
+ return numeric_df.corr().to_dict()
@@ -0,0 +1,23 @@
1
+ from .base import BasePlugin
2
+
3
+
4
+ class DuplicatesPlugin(BasePlugin):
5
+ """Detect duplicate rows and constant (zero-variance) columns."""
6
+
7
+ name = "duplicates"
8
+
9
+ def run(self, df):
10
+ n_rows = len(df)
11
+
12
+ duplicate_rows = int(df.duplicated().sum())
13
+ duplicate_pct = round(duplicate_rows / n_rows, 4) if n_rows > 0 else 0.0
14
+
15
+ constant_columns = [
16
+ col for col in df.columns if df[col].nunique(dropna=False) <= 1
17
+ ]
18
+
19
+ return {
20
+ "duplicate_rows": duplicate_rows,
21
+ "duplicate_rows_pct": duplicate_pct,
22
+ "constant_columns": constant_columns,
23
+ }
@@ -0,0 +1,24 @@
1
+ import base64
2
+ from .base import BasePlugin
3
+
4
+ class EncodingDetectionPlugin(BasePlugin):
5
+ name = "encoding"
6
+
7
+ def is_base64(self, s):
8
+ try:
9
+ return base64.b64encode(base64.b64decode(s)) == s.encode()
10
+ except:
11
+ return False
12
+
13
+ def run(self, df):
14
+ results = {}
15
+
16
+ for col in df.columns:
17
+ if df[col].dtype == "object":
18
+ sample = df[col].dropna().astype(str).head(20)
19
+ count = sum(self.is_base64(x) for x in sample)
20
+
21
+ if count > 5:
22
+ results[col] = "possible_base64"
23
+
24
+ return results
@@ -0,0 +1,10 @@
1
+ from .base import BasePlugin
2
+
3
+ class MissingDataPlugin(BasePlugin):
4
+ name = "missing"
5
+
6
+ def run(self, df):
7
+ return {
8
+ col: float(df[col].isna().mean())
9
+ for col in df.columns
10
+ }
@@ -0,0 +1,17 @@
1
+ from .base import BasePlugin
2
+
3
+ class OutlierPlugin(BasePlugin):
4
+ name = "outliers"
5
+
6
+ def run(self, df):
7
+ result = {}
8
+
9
+ for col in df.select_dtypes(include="number").columns:
10
+ q1 = df[col].quantile(0.25)
11
+ q3 = df[col].quantile(0.75)
12
+ iqr = q3 - q1
13
+
14
+ outliers = df[(df[col] < q1 - 1.5*iqr) | (df[col] > q3 + 1.5*iqr)]
15
+ result[col] = int(len(outliers))
16
+
17
+ return result
noweda/plugins/pii.py ADDED
@@ -0,0 +1,18 @@
1
+ import re
2
+ from .base import BasePlugin
3
+
4
+ class PIIDetectorPlugin(BasePlugin):
5
+ name = "pii"
6
+
7
+ EMAIL_REGEX = r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"
8
+
9
+ def run(self, df):
10
+ findings = {}
11
+
12
+ for col in df.columns:
13
+ if df[col].dtype == "object":
14
+ matches = df[col].astype(str).str.contains(self.EMAIL_REGEX, regex=True).sum()
15
+ if matches > 0:
16
+ findings[col] = {"emails_detected": int(matches)}
17
+
18
+ return findings
@@ -0,0 +1,68 @@
1
+ from .base import BasePlugin
2
+
3
+
4
+ # Cardinality thresholds
5
+ _ID_UNIQUENESS = 0.95 # column is likely an ID/key
6
+ _CAT_CARDINALITY = 0.05 # column is likely categorical
7
+
8
+
9
+ class SchemaPlugin(BasePlugin):
10
+ """Infer semantic column roles: id, categorical, numeric, datetime, text."""
11
+
12
+ name = "schema"
13
+
14
+ def _infer_role(self, series, n_rows):
15
+ dtype = series.dtype
16
+ n_unique = series.nunique()
17
+ uniqueness = n_unique / n_rows if n_rows > 0 else 0
18
+
19
+ # Datetime detection
20
+ if dtype.kind == "M":
21
+ return "datetime"
22
+
23
+ # Try to parse object columns as datetime
24
+ if dtype == "object":
25
+ sample = series.dropna().astype(str).head(20)
26
+ import pandas as pd
27
+ try:
28
+ pd.to_datetime(sample)
29
+ return "datetime"
30
+ except Exception:
31
+ pass
32
+
33
+ # Numeric
34
+ if dtype.kind in ("i", "u", "f"):
35
+ # Only flag integer-typed columns with perfect uniqueness as IDs.
36
+ # Float columns (salary, score) are almost never true keys.
37
+ # Require uniqueness == 1.0 so numeric columns with any duplicates
38
+ # (like age appearing twice) are NOT incorrectly flagged.
39
+ if dtype.kind in ("i", "u") and uniqueness == 1.0 and n_unique > 5:
40
+ return "id_candidate"
41
+ if uniqueness <= _CAT_CARDINALITY and n_unique <= 20:
42
+ return "categorical_numeric"
43
+ return "numeric"
44
+
45
+ # Object / string
46
+ if dtype == "object":
47
+ # Require near-perfect uniqueness for string ID detection
48
+ if uniqueness >= 0.98 and n_unique > 5:
49
+ return "id_candidate"
50
+ if uniqueness <= _CAT_CARDINALITY and n_unique <= 50:
51
+ return "categorical"
52
+ return "text"
53
+
54
+ return "unknown"
55
+
56
+ def run(self, df):
57
+ n_rows = len(df)
58
+ result = {}
59
+
60
+ for col in df.columns:
61
+ result[col] = {
62
+ "dtype": str(df[col].dtype),
63
+ "role": self._infer_role(df[col], n_rows),
64
+ "unique": int(df[col].nunique()),
65
+ "uniqueness_ratio": round(df[col].nunique() / n_rows, 4) if n_rows > 0 else 0,
66
+ }
67
+
68
+ return result
@@ -0,0 +1,40 @@
1
+ from .base import BasePlugin
2
+
3
+
4
+ class StatsPlugin(BasePlugin):
5
+ """Compute descriptive statistics for numeric and categorical columns."""
6
+
7
+ name = "stats"
8
+
9
+ def run(self, df):
10
+ result = {}
11
+
12
+ for col in df.columns:
13
+ series = df[col]
14
+ col_stats = {
15
+ "dtype": str(series.dtype),
16
+ "count": int(series.count()),
17
+ "missing": int(series.isna().sum()),
18
+ "unique": int(series.nunique()),
19
+ }
20
+
21
+ if series.dtype.kind in ("i", "u", "f"):
22
+ col_stats.update({
23
+ "mean": float(series.mean()),
24
+ "median": float(series.median()),
25
+ "std": float(series.std()),
26
+ "min": float(series.min()),
27
+ "max": float(series.max()),
28
+ "q25": float(series.quantile(0.25)),
29
+ "q75": float(series.quantile(0.75)),
30
+ "skewness": float(series.skew()),
31
+ })
32
+ elif series.dtype == "object" or str(series.dtype) == "category":
33
+ top = series.value_counts()
34
+ if not top.empty:
35
+ col_stats["top_value"] = str(top.index[0])
36
+ col_stats["top_freq"] = int(top.iloc[0])
37
+
38
+ result[col] = col_stats
39
+
40
+ return result
File without changes
noweda/report/html.py ADDED
@@ -0,0 +1,283 @@
1
+ import json
2
+ from datetime import datetime
3
+
4
+
5
+ def generate_html_report(report, output_path):
6
+ scores = report.get("scores", {})
7
+ insights = report.get("insights", [])
8
+ results = report.get("results", {})
9
+
10
+ quality = scores.get("data_quality", 0)
11
+ risk = scores.get("risk", 0)
12
+ readiness = scores.get("model_readiness", 0)
13
+
14
+ quality_color = _score_color(quality, invert=False)
15
+ risk_color = _score_color(risk, invert=True, max_val=100)
16
+ readiness_color = _score_color(readiness, invert=False)
17
+
18
+ insights_html = "".join(
19
+ f'<li class="insight-item">{_escape(i)}</li>' for i in insights
20
+ )
21
+
22
+ schema_html = _schema_table(results.get("schema", {}))
23
+ missing_html = _missing_table(results.get("missing", {}))
24
+ duplicates_html = _duplicates_section(results.get("duplicates", {}))
25
+ outliers_html = _outliers_table(results.get("outliers", {}))
26
+ pii_html = _pii_table(results.get("pii", {}))
27
+ encoding_html = _encoding_table(results.get("encoding", {}))
28
+
29
+ generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
30
+
31
+ html = f"""<!DOCTYPE html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="UTF-8" />
35
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
36
+ <title>NowEDA Report</title>
37
+ <style>
38
+ :root {{
39
+ --bg: #0f1117;
40
+ --surface: #1a1d27;
41
+ --surface2: #22263a;
42
+ --border: #2e3250;
43
+ --text: #e2e8f0;
44
+ --muted: #8892b0;
45
+ --accent: #7c6af7;
46
+ --green: #4ade80;
47
+ --yellow: #facc15;
48
+ --red: #f87171;
49
+ }}
50
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
51
+ body {{ background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; }}
52
+ .container {{ max-width: 1100px; margin: 0 auto; padding: 32px 24px; }}
53
+ .header {{ margin-bottom: 36px; border-bottom: 1px solid var(--border); padding-bottom: 20px; }}
54
+ .header h1 {{ font-size: 28px; font-weight: 700; color: var(--accent); letter-spacing: -0.5px; }}
55
+ .header .meta {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
56
+ .scores-grid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }}
57
+ .score-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; text-align: center; }}
58
+ .score-card .label {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }}
59
+ .score-card .value {{ font-size: 40px; font-weight: 800; line-height: 1; }}
60
+ .score-card .sub {{ font-size: 11px; color: var(--muted); margin-top: 4px; }}
61
+ .section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 24px; }}
62
+ .section h2 {{ font-size: 16px; font-weight: 600; margin-bottom: 16px; color: var(--text); border-bottom: 1px solid var(--border); padding-bottom: 10px; }}
63
+ .insight-list {{ list-style: none; display: flex; flex-direction: column; gap: 8px; }}
64
+ .insight-item {{ background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 4px; padding: 10px 14px; font-size: 13px; line-height: 1.5; }}
65
+ table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
66
+ th {{ text-align: left; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 12px; border-bottom: 1px solid var(--border); }}
67
+ td {{ padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--text); }}
68
+ tr:last-child td {{ border-bottom: none; }}
69
+ tr:hover td {{ background: var(--surface2); }}
70
+ .tag {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }}
71
+ .tag-id {{ background: #312e81; color: #a5b4fc; }}
72
+ .tag-cat {{ background: #164e63; color: #67e8f9; }}
73
+ .tag-num {{ background: #14532d; color: #86efac; }}
74
+ .tag-text {{ background: #44403c; color: #d6d3d1; }}
75
+ .tag-dt {{ background: #4a1d96; color: #c4b5fd; }}
76
+ .tag-unk {{ background: #2d2d2d; color: #9ca3af; }}
77
+ .pct-bar {{ background: var(--surface2); border-radius: 4px; height: 6px; width: 100px; display: inline-block; vertical-align: middle; margin-left: 8px; }}
78
+ .pct-fill {{ height: 100%; border-radius: 4px; }}
79
+ .empty {{ color: var(--muted); font-style: italic; font-size: 13px; }}
80
+ .footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border); }}
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="container">
85
+
86
+ <div class="header">
87
+ <h1>NowEDA Report</h1>
88
+ <div class="meta">Generated: {generated_at}</div>
89
+ </div>
90
+
91
+ <div class="scores-grid">
92
+ <div class="score-card">
93
+ <div class="label">Data Quality</div>
94
+ <div class="value" style="color:{quality_color}">{quality}</div>
95
+ <div class="sub">out of 100</div>
96
+ </div>
97
+ <div class="score-card">
98
+ <div class="label">Risk Level</div>
99
+ <div class="value" style="color:{risk_color}">{risk}</div>
100
+ <div class="sub">higher = more risk</div>
101
+ </div>
102
+ <div class="score-card">
103
+ <div class="label">Model Readiness</div>
104
+ <div class="value" style="color:{readiness_color}">{readiness}</div>
105
+ <div class="sub">out of 100</div>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="section">
110
+ <h2>Insights</h2>
111
+ {"<ul class='insight-list'>" + insights_html + "</ul>" if insights else "<p class='empty'>No insights generated.</p>"}
112
+ </div>
113
+
114
+ {schema_html}
115
+ {missing_html}
116
+ {duplicates_html}
117
+ {outliers_html}
118
+ {pii_html}
119
+ {encoding_html}
120
+
121
+ <div class="footer">
122
+ Built with <strong>NowEDA</strong> — Automated EDA Framework
123
+ </div>
124
+
125
+ </div>
126
+ </body>
127
+ </html>"""
128
+
129
+ with open(output_path, "w", encoding="utf-8") as f:
130
+ f.write(html)
131
+
132
+
133
+ # ------------------------------------------------------------------
134
+ # Helpers
135
+ # ------------------------------------------------------------------
136
+
137
+ def _escape(s):
138
+ return (
139
+ str(s)
140
+ .replace("&", "&amp;")
141
+ .replace("<", "&lt;")
142
+ .replace(">", "&gt;")
143
+ .replace('"', "&quot;")
144
+ )
145
+
146
+
147
+ def _score_color(val, invert=False, max_val=100):
148
+ """Return a CSS color (green/yellow/red) based on value."""
149
+ ratio = val / max_val if max_val else 0
150
+ if invert:
151
+ ratio = 1 - min(ratio, 1)
152
+ if ratio >= 0.75:
153
+ return "var(--green)"
154
+ elif ratio >= 0.4:
155
+ return "var(--yellow)"
156
+ return "var(--red)"
157
+
158
+
159
+ def _role_tag(role):
160
+ mapping = {
161
+ "id_candidate": ("ID", "tag-id"),
162
+ "categorical": ("Categorical", "tag-cat"),
163
+ "categorical_numeric": ("Cat. Numeric", "tag-cat"),
164
+ "numeric": ("Numeric", "tag-num"),
165
+ "text": ("Text", "tag-text"),
166
+ "datetime": ("Datetime", "tag-dt"),
167
+ "unknown": ("Unknown", "tag-unk"),
168
+ }
169
+ label, css = mapping.get(role, (role, "tag-unk"))
170
+ return f'<span class="tag {css}">{label}</span>'
171
+
172
+
173
+ def _schema_table(schema):
174
+ if not schema:
175
+ return ""
176
+ rows = ""
177
+ for col, info in schema.items():
178
+ rows += (
179
+ f"<tr>"
180
+ f"<td>{_escape(col)}</td>"
181
+ f"<td>{_escape(info.get('dtype', ''))}</td>"
182
+ f"<td>{_role_tag(info.get('role', 'unknown'))}</td>"
183
+ f"<td>{info.get('unique', '')}</td>"
184
+ f"<td>{info.get('uniqueness_ratio', ''):.2%}</td>"
185
+ f"</tr>"
186
+ )
187
+ return f"""<div class="section">
188
+ <h2>Column Schema</h2>
189
+ <table>
190
+ <thead><tr><th>Column</th><th>Dtype</th><th>Role</th><th>Unique Values</th><th>Uniqueness</th></tr></thead>
191
+ <tbody>{rows}</tbody>
192
+ </table>
193
+ </div>"""
194
+
195
+
196
+ def _missing_table(missing):
197
+ if not missing:
198
+ return ""
199
+ rows = ""
200
+ for col, pct in sorted(missing.items(), key=lambda x: -x[1]):
201
+ pct_int = int(pct * 100)
202
+ bar_color = "#f87171" if pct > 0.3 else ("#facc15" if pct > 0 else "#4ade80")
203
+ bar = (
204
+ f'<span class="pct-bar"><span class="pct-fill" '
205
+ f'style="width:{pct_int}%;background:{bar_color}"></span></span>'
206
+ )
207
+ rows += f"<tr><td>{_escape(col)}</td><td>{pct:.1%} {bar}</td></tr>"
208
+ return f"""<div class="section">
209
+ <h2>Missing Values</h2>
210
+ <table>
211
+ <thead><tr><th>Column</th><th>Missing %</th></tr></thead>
212
+ <tbody>{rows}</tbody>
213
+ </table>
214
+ </div>"""
215
+
216
+
217
+ def _duplicates_section(duplicates):
218
+ if not duplicates:
219
+ return ""
220
+ dup_rows = duplicates.get("duplicate_rows", 0)
221
+ dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
222
+ const_cols = duplicates.get("constant_columns", [])
223
+ const_str = ", ".join(const_cols) if const_cols else "None"
224
+ return f"""<div class="section">
225
+ <h2>Duplicates &amp; Constants</h2>
226
+ <table>
227
+ <thead><tr><th>Metric</th><th>Value</th></tr></thead>
228
+ <tbody>
229
+ <tr><td>Duplicate rows</td><td>{dup_rows} ({dup_pct:.1%})</td></tr>
230
+ <tr><td>Constant columns</td><td>{_escape(const_str)}</td></tr>
231
+ </tbody>
232
+ </table>
233
+ </div>"""
234
+
235
+
236
+ def _outliers_table(outliers):
237
+ if not outliers:
238
+ return ""
239
+ rows = "".join(
240
+ f"<tr><td>{_escape(col)}</td><td>{count}</td></tr>"
241
+ for col, count in sorted(outliers.items(), key=lambda x: -x[1])
242
+ if count > 0
243
+ )
244
+ if not rows:
245
+ return ""
246
+ return f"""<div class="section">
247
+ <h2>Outliers (IQR Method)</h2>
248
+ <table>
249
+ <thead><tr><th>Column</th><th>Outlier Count</th></tr></thead>
250
+ <tbody>{rows}</tbody>
251
+ </table>
252
+ </div>"""
253
+
254
+
255
+ def _pii_table(pii):
256
+ if not pii:
257
+ return ""
258
+ rows = ""
259
+ for col, info in pii.items():
260
+ rows += f"<tr><td>{_escape(col)}</td><td>{_escape(str(info))}</td></tr>"
261
+ return f"""<div class="section" style="border-color:#f87171">
262
+ <h2>PII Detection</h2>
263
+ <table>
264
+ <thead><tr><th>Column</th><th>Finding</th></tr></thead>
265
+ <tbody>{rows}</tbody>
266
+ </table>
267
+ </div>"""
268
+
269
+
270
+ def _encoding_table(encoding):
271
+ if not encoding:
272
+ return ""
273
+ rows = "".join(
274
+ f"<tr><td>{_escape(col)}</td><td>{_escape(enc_type)}</td></tr>"
275
+ for col, enc_type in encoding.items()
276
+ )
277
+ return f"""<div class="section" style="border-color:#facc15">
278
+ <h2>Encoding Detection</h2>
279
+ <table>
280
+ <thead><tr><th>Column</th><th>Detected Encoding</th></tr></thead>
281
+ <tbody>{rows}</tbody>
282
+ </table>
283
+ </div>"""
File without changes
@@ -0,0 +1,86 @@
1
+ class Scorer:
2
+ """
3
+ Compute data quality and risk scores from plugin results.
4
+
5
+ Scores:
6
+ data_quality : 0-100 (higher = cleaner)
7
+ risk : 0+ (higher = more sensitive/risky)
8
+ model_readiness : 0-100 (higher = more ready for ML)
9
+ """
10
+
11
+ def compute(self, results):
12
+ scores = {
13
+ "data_quality": 100,
14
+ "risk": 0,
15
+ "model_readiness": 100,
16
+ }
17
+
18
+ self._penalise_missing(results.get("missing", {}), scores)
19
+ self._penalise_duplicates(results.get("duplicates", {}), scores)
20
+ self._penalise_outliers(results.get("outliers", {}), scores)
21
+ self._penalise_skew(results.get("stats", {}), scores)
22
+ self._add_pii_risk(results.get("pii", {}), scores)
23
+ self._add_encoding_risk(results.get("encoding", {}), scores)
24
+ self._penalise_schema(results.get("schema", {}), scores)
25
+
26
+ # Clamp quality and readiness to [0, 100]
27
+ scores["data_quality"] = max(0, min(100, scores["data_quality"]))
28
+ scores["model_readiness"] = max(0, min(100, scores["model_readiness"]))
29
+
30
+ return scores
31
+
32
+ # ------------------------------------------------------------------
33
+
34
+ def _penalise_missing(self, missing, scores):
35
+ for pct in missing.values():
36
+ if pct > 0.5:
37
+ scores["data_quality"] -= 10
38
+ scores["model_readiness"] -= 15
39
+ elif pct > 0.3:
40
+ scores["data_quality"] -= 5
41
+ scores["model_readiness"] -= 8
42
+ elif pct > 0:
43
+ scores["data_quality"] -= 2
44
+ scores["model_readiness"] -= 3
45
+
46
+ def _penalise_duplicates(self, duplicates, scores):
47
+ dup_pct = duplicates.get("duplicate_rows_pct", 0.0)
48
+ const_cols = duplicates.get("constant_columns", [])
49
+
50
+ if dup_pct > 0.1:
51
+ scores["data_quality"] -= 10
52
+ elif dup_pct > 0:
53
+ scores["data_quality"] -= 3
54
+
55
+ scores["data_quality"] -= len(const_cols) * 3
56
+ scores["model_readiness"] -= len(const_cols) * 5
57
+
58
+ def _penalise_outliers(self, outliers, scores):
59
+ total = sum(outliers.values())
60
+ if total > 50:
61
+ scores["data_quality"] -= 10
62
+ scores["model_readiness"] -= 10
63
+ elif total > 10:
64
+ scores["data_quality"] -= 5
65
+ scores["model_readiness"] -= 5
66
+
67
+ def _penalise_skew(self, stats, scores):
68
+ heavy_skew_cols = [
69
+ col for col, s in stats.items()
70
+ if s.get("skewness") is not None and abs(s["skewness"]) > 2
71
+ ]
72
+ scores["model_readiness"] -= len(heavy_skew_cols) * 5
73
+
74
+ def _add_pii_risk(self, pii, scores):
75
+ scores["risk"] += len(pii) * 15
76
+
77
+ def _add_encoding_risk(self, encoding, scores):
78
+ scores["risk"] += len(encoding) * 10
79
+
80
+ def _penalise_schema(self, schema, scores):
81
+ # All-text or all-unknown columns reduce model readiness
82
+ untyped = sum(
83
+ 1 for v in schema.values()
84
+ if v.get("role") in ("text", "unknown")
85
+ )
86
+ scores["model_readiness"] -= untyped * 3
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: noweda
3
+ Version: 0.1.0
4
+ Summary: Automated EDA with insights, scoring, and security-aware detection — built as a native pandas extension.
5
+ Author-email: Daniel Peng <danielpeng95@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/codewithdaniel1/NowEDA
8
+ Project-URL: Repository, https://github.com/codewithdaniel1/NowEDA
9
+ Project-URL: Bug Tracker, https://github.com/codewithdaniel1/NowEDA/issues
10
+ Keywords: eda,data-analysis,pandas,data-quality,pii-detection,automated-eda,profiling
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pandas>=1.3
26
+ Requires-Dist: numpy>=1.21
27
+ Requires-Dist: openpyxl>=3.0
28
+ Dynamic: license-file
29
+
30
+ # NowEDA
31
+
32
+ **Automated Exploratory Data Analysis — built as a native pandas extension.**
33
+
34
+ NowEDA is a lightweight, modular Python framework that turns any dataset into instant insight. Load any file, call `df.noweda.*`, and get a full EDA report — including data quality scoring, PII detection, outlier analysis, and human-readable insights — with zero boilerplate.
35
+
36
+ ---
37
+
38
+ ## Features
39
+
40
+ | Feature | Description |
41
+ |---|---|
42
+ | Universal ingestion | CSV, Excel, JSON, XML, HTML |
43
+ | Native pandas accessor | `df.noweda.*` — feels like pandas |
44
+ | Plugin architecture | Every analysis is a swappable plugin |
45
+ | Schema inference | Auto-detects IDs, categoricals, datetimes, text |
46
+ | Data quality scoring | 0–100 quality + model-readiness score |
47
+ | Risk scoring | PII and encoding risk level |
48
+ | PII detection | Email addresses + extensible patterns |
49
+ | Encoding detection | Base64 and obfuscation signals |
50
+ | Outlier detection | IQR-based, per numeric column |
51
+ | Duplicate detection | Exact row duplicates + constant columns |
52
+ | Actionable insights | Human-readable text, not just numbers |
53
+ | HTML report | Dark-themed, stakeholder-ready export |
54
+ | CLI | One-liner from the terminal |
55
+
56
+ ---
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ cd NowEDA
62
+ pip install -e .
63
+ ```
64
+
65
+ > Requires Python 3.8+ and pandas 1.3+.
66
+
67
+ ---
68
+
69
+ ## Quick Start
70
+
71
+ ```python
72
+ import noweda
73
+
74
+ df = noweda.read("data.csv")
75
+
76
+ # Still a regular pandas DataFrame — nothing changes
77
+ print(df.head())
78
+ print(df.describe())
79
+
80
+ # NowEDA layer
81
+ print(df.noweda.insights()) # human-readable insight list
82
+ print(df.noweda.score()) # quality, risk, model_readiness
83
+ print(df.noweda.summary()) # raw plugin results
84
+ report = df.noweda.report() # full structured dict
85
+ ```
86
+
87
+ ### All supported formats
88
+
89
+ ```python
90
+ df = noweda.read("data.csv")
91
+ df = noweda.read("data.xlsx", sheet_name="Sheet1")
92
+ df = noweda.read("data.json")
93
+ df = noweda.read("data.xml")
94
+ df = noweda.read("data.html")
95
+ ```
96
+
97
+ Any `**kwargs` are forwarded to the underlying pandas reader.
98
+
99
+ ---
100
+
101
+ ## CLI
102
+
103
+ ```bash
104
+ # Print insights and scores to the terminal
105
+ noweda data.csv
106
+
107
+ # Export a dark-themed HTML report
108
+ noweda data.csv --html report.html
109
+
110
+ # Export a JSON report
111
+ noweda data.csv --json report.json
112
+
113
+ # Both at once
114
+ noweda data.csv --html report.html --json report.json
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Score Breakdown
120
+
121
+ | Score | Range | Meaning |
122
+ |---|---|---|
123
+ | `data_quality` | 0–100 | Penalised for missing values, duplicates, constants, outliers |
124
+ | `model_readiness` | 0–100 | Penalised for skew, untyped columns, high missingness |
125
+ | `risk` | 0+ | Added per PII column (+15) and encoded column (+10) |
126
+
127
+ ---
128
+
129
+ ## Plugin System
130
+
131
+ Every analysis step is an independent plugin. You can swap, extend, or disable plugins.
132
+
133
+ ```python
134
+ from noweda.core.engine import AutoEDAEngine
135
+ from noweda.plugins.missing import MissingDataPlugin
136
+ from noweda.plugins.pii import PIIDetectorPlugin
137
+
138
+ # Run only the plugins you want
139
+ engine = AutoEDAEngine([MissingDataPlugin(), PIIDetectorPlugin()])
140
+ report = engine.run_df(df)
141
+ ```
142
+
143
+ ### Built-in plugins
144
+
145
+ | Plugin | Name key | What it detects |
146
+ |---|---|---|
147
+ | `SchemaPlugin` | `schema` | Column roles: id, categorical, numeric, datetime, text |
148
+ | `StatsPlugin` | `stats` | Descriptive stats: mean, median, std, skewness, etc. |
149
+ | `MissingDataPlugin` | `missing` | Per-column missing rate |
150
+ | `DuplicatesPlugin` | `duplicates` | Duplicate rows, constant columns |
151
+ | `CorrelationPlugin` | `correlation` | Pearson correlation matrix (numeric columns) |
152
+ | `OutlierPlugin` | `outliers` | IQR-based outlier counts per column |
153
+ | `PIIDetectorPlugin` | `pii` | Email addresses (extensible to SSN, phone, etc.) |
154
+ | `EncodingDetectionPlugin` | `encoding` | Base64-encoded strings |
155
+
156
+ ### Writing a custom plugin
157
+
158
+ ```python
159
+ from noweda.plugins.base import BasePlugin
160
+
161
+ class MyPlugin(BasePlugin):
162
+ name = "my_check"
163
+
164
+ def run(self, df):
165
+ # return any JSON-serialisable dict
166
+ return {"total_rows": len(df)}
167
+ ```
168
+
169
+ ---
170
+
171
+ ## HTML Report
172
+
173
+ ```bash
174
+ noweda examples/sample.csv --html report.html
175
+ ```
176
+
177
+ The report includes:
178
+
179
+ - Score cards (quality, risk, model readiness)
180
+ - Actionable insights list
181
+ - Column schema table with inferred roles
182
+ - Missing value bars
183
+ - Duplicate and constant column summary
184
+ - Outlier counts
185
+ - PII findings (highlighted)
186
+ - Encoding signals (highlighted)
187
+
188
+ ---
189
+
190
+ ## Running Tests
191
+
192
+ ```bash
193
+ pip install pytest
194
+ python -m pytest tests/ -v
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Project Structure
200
+
201
+ ```
202
+ NowEDA/
203
+ ├── noweda/
204
+ │ ├── __init__.py # exposes noweda.read()
205
+ │ ├── io.py # file ingestion (all formats)
206
+ │ ├── accessor.py # df.noweda.* pandas accessor
207
+ │ ├── core/
208
+ │ │ └── engine.py # orchestrates plugins → scorer → insights
209
+ │ ├── plugins/
210
+ │ │ ├── base.py
211
+ │ │ ├── schema.py
212
+ │ │ ├── stats.py
213
+ │ │ ├── missing.py
214
+ │ │ ├── duplicates.py
215
+ │ │ ├── correlation.py
216
+ │ │ ├── outliers.py
217
+ │ │ ├── pii.py
218
+ │ │ └── encoding.py
219
+ │ ├── scoring/
220
+ │ │ └── scorer.py
221
+ │ ├── insights/
222
+ │ │ └── generator.py
223
+ │ ├── report/
224
+ │ │ └── html.py
225
+ │ └── cli.py
226
+ ├── examples/
227
+ │ └── sample.csv
228
+ ├── tests/
229
+ │ └── test_basic.py
230
+ └── pyproject.toml
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Roadmap
236
+
237
+ - [ ] Visualization layer (histograms, correlation heatmap)
238
+ - [ ] Dataset fingerprinting / hash-based change detection
239
+ - [ ] Additional PII patterns (phone, SSN, credit card)
240
+ - [ ] Streaming / chunked ingestion for large files
241
+ - [ ] PyPI publish
242
+ - [ ] Web dashboard UI
243
+
244
+ ---
245
+
246
+ ## Author
247
+
248
+ **Daniel Peng** — [danielpeng@osiris.cyber.nyu.edu](mailto:)
@@ -0,0 +1,28 @@
1
+ noweda/__init__.py,sha256=SfZISfrn1z9pCXoXIhKySrUjdUQyoRP3msU0qRa44Lg,77
2
+ noweda/accessor.py,sha256=zra5VAyWEfrjqd0kvP5ic15HzenJLY-i7cHqRZI9lL8,821
3
+ noweda/cli.py,sha256=p6MEIJXwlKXn-l7sBCwPvakVBjrwfbUTGyNkQsuG_6I,813
4
+ noweda/io.py,sha256=SMYi1pFcxO-JedqpeVMcq-Y08td5sMqKffHIkgie7h8,559
5
+ noweda/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ noweda/core/engine.py,sha256=ZxDO71rL2RUsaal4_iZEvPvy7h44W4gEmXWUAx8LLu8,549
7
+ noweda/insights/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ noweda/insights/generator.py,sha256=3sc4YUuxapOIeonUGxq_BZoV7jhRzwSRPscooj-SQgY,6336
9
+ noweda/plugins/__init__.py,sha256=M2grrY-qz8Q8btXdvCLIqOYOd5_9a8DGeh7bnx2a3qo,570
10
+ noweda/plugins/base.py,sha256=Iu-v-j8sm_hKiSIalCoBD1Uq5btS96i5Ed3aeOqy8-s,94
11
+ noweda/plugins/correlation.py,sha256=36sfsDHAoq_it-md6ySxDbjYVTQHDlYCPd98XMpBnwg,266
12
+ noweda/plugins/duplicates.py,sha256=7yjvYLjYPEJEQ5UFdgkYbtj3ul4uFaloZjS4vg_jFU4,634
13
+ noweda/plugins/encoding.py,sha256=BlN0kn02ZRBx3w6DI35yN4RrI3hrLfk57PPljntaGtw,616
14
+ noweda/plugins/missing.py,sha256=gcPgk5TSkpRi_0xABi8BQsAxHevUdPUI91HxbFRg0L0,219
15
+ noweda/plugins/outliers.py,sha256=DRmqFyimIBMHLbH_aXKH3nv3Xp18gHd9GFDd6vA9oRQ,447
16
+ noweda/plugins/pii.py,sha256=w3UYxeM7nnjEVJ4yHxCkaDl8ubodh62wTckz05mGlMw,505
17
+ noweda/plugins/schema.py,sha256=GwKNCgFEijZKbC6FuPuzTIV9Z5FQec6PUQ6SuRwPQ0A,2277
18
+ noweda/plugins/stats.py,sha256=8vRWxQvf5gxvFVdq_laplt06uDVZO36Qy2VTk-VLrpo,1361
19
+ noweda/report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ noweda/report/html.py,sha256=9dkQjDcgnJz2MaXw7DiQRLuBR2GU_pwHtJ1WYntIQvY,10192
21
+ noweda/scoring/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ noweda/scoring/scorer.py,sha256=gG5ttXEsroYvEAsmqP-MbuAV-JLfb3dqXs3sDkmfvD0,3103
23
+ noweda-0.1.0.dist-info/licenses/LICENSE,sha256=7zCIaXWjgEjqbSjHZfGkaVjwpZzrnC8PExoh-HArwog,1068
24
+ noweda-0.1.0.dist-info/METADATA,sha256=UlIcU3LVuLuFhzlXAt9lMWdnz1N71nLKoblXv-JsXrA,7024
25
+ noweda-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
26
+ noweda-0.1.0.dist-info/entry_points.txt,sha256=bPHLmGelOac4dnTCaMjzLQqdn-N3ZDXzA-3iceAXn-Q,43
27
+ noweda-0.1.0.dist-info/top_level.txt,sha256=z79WDaLX_ynq-UdeEYwwSgHLArVUj--W5hueoZF8pig,7
28
+ noweda-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ noweda = noweda.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Peng
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
+ noweda