flasheda 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: flasheda
3
+ Version: 0.1.2
4
+ Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
+ Home-page: https://github.com/sjapanjots/flasheda
6
+ Author: Japanjot Singh
7
+ Author-email: sjapanjots@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pandas>=1.3.0
13
+ Requires-Dist: numpy>=1.21.0
14
+ Requires-Dist: scipy>=1.7.0
15
+ Requires-Dist: rich>=12.0.0
16
+ Requires-Dist: jinja2>=3.0.0
17
+ Requires-Dist: matplotlib>=3.4.0
18
+ Requires-Dist: fpdf2>=2.7.0
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: requires-dist
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # ⚡ FlashEDA
30
+
31
+ Constant-time Exploratory Data Analysis — one line, fixed time, any dataset size.
32
+
33
+ ## Install
34
+ ```bash
35
+ pip install flasheda
36
+ ```
37
+
38
+ ## Usage
39
+ ```python
40
+ import flasheda
41
+
42
+ report = flasheda.analyze(df) # works on 1K or 50M rows in the same time
43
+ report.show() # rich console output
44
+ report.save_html("report.html") # browser report
45
+ ```
46
+
47
+ ## How it works
48
+ FlashEDA uses **reservoir sampling** to always analyse exactly 5,000 rows,
49
+ regardless of dataset size. All analyzers run in parallel via ThreadPoolExecutor.
@@ -11,7 +11,7 @@ from .sampler import sample, get_sample_size, set_sample_size
11
11
  from .analyzers import overview, missing, numeric, categorical, correlation
12
12
  from .report import EDAReport
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.1.2"
15
15
  __all__ = ["analyze", "set_sample_size", "get_sample_size", "EDAReport"]
16
16
 
17
17
 
@@ -21,8 +21,26 @@ def analyze(
21
21
  *,
22
22
  show: bool = False,
23
23
  random_state: int = 42,
24
+ save_pdf: bool = True, # ← NEW: auto-save PDF
25
+ pdf_path: str = "flasheda_report.pdf", # ← NEW: PDF file name
24
26
  ) -> EDAReport:
25
-
27
+ """
28
+ Perform constant-time EDA on any dataset.
29
+
30
+ Parameters
31
+ ----------
32
+ source : pandas DataFrame or path to CSV / Parquet file
33
+ n : sample size (default 5000)
34
+ show : print rich console summary
35
+ random_state : RNG seed
36
+ save_pdf : automatically save PDF report (default True)
37
+ pdf_path : path/name of the PDF file to save
38
+
39
+ Example
40
+ -------
41
+ >>> report = flasheda.analyze(df)
42
+ # flasheda_report.pdf is saved automatically
43
+ """
26
44
  t0 = time.perf_counter()
27
45
  n = n or get_sample_size()
28
46
 
@@ -85,14 +103,8 @@ def analyze(
85
103
  if show:
86
104
  report.show()
87
105
 
88
- return report
89
-
90
-
91
- def get_sample_size() -> int:
92
- from .sampler import get_sample_size as _get
93
- return _get()
94
-
106
+ # ── Auto-save PDF immediately after analysis ───────────────────────────
107
+ if save_pdf:
108
+ report.save_pdf(pdf_path)
95
109
 
96
- def set_sample_size(n: int) -> None:
97
- from .sampler import set_sample_size as _set
98
- _set(n)
110
+ return report
@@ -0,0 +1,522 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ @dataclass
8
+ class EDAReport:
9
+ overview: Dict[str, Any]
10
+ missing: Dict[str, Any]
11
+ numeric: Dict[str, Any]
12
+ categorical: Dict[str, Any]
13
+ correlation: Dict[str, Any]
14
+ sample_size: int
15
+ elapsed_seconds: float
16
+ original_shape: tuple
17
+ warnings: list[str] = field(default_factory=list)
18
+
19
+ # ──────────────────────────────────────────────────────────────────────
20
+ # Console
21
+ # ──────────────────────────────────────────────────────────────────────
22
+
23
+ def show(self) -> None:
24
+ from rich.console import Console
25
+ from rich.table import Table
26
+ from rich import box
27
+ from rich.panel import Panel
28
+
29
+ console = Console()
30
+ orig_rows = self.original_shape[0]
31
+ ncols = self.original_shape[1]
32
+
33
+ console.print(
34
+ Panel(
35
+ f"[bold cyan]FlashEDA Report[/bold cyan]\n"
36
+ f"Dataset: [green]{orig_rows:,}[/green] rows × [green]{ncols}[/green] cols "
37
+ f"| Analysed on [yellow]{self.sample_size:,}[/yellow] sampled rows "
38
+ f"| ⏱ [white]{self.elapsed_seconds:.2f}s[/white]",
39
+ expand=False,
40
+ )
41
+ )
42
+
43
+ if self.warnings:
44
+ for w in self.warnings:
45
+ console.print(f" [bold yellow]⚠ {w}[/bold yellow]")
46
+ console.print()
47
+
48
+ miss = self.missing
49
+ console.print(
50
+ f"[bold]Missing values[/bold] overall: [red]{miss['overall_null_pct']}%[/red] "
51
+ f"| rows with any null: [red]{miss['rows_with_any_null_pct']}%[/red]"
52
+ )
53
+ if miss["critical_columns"]:
54
+ console.print(f" Critical (>50% null): [red]{', '.join(miss['critical_columns'])}[/red]")
55
+ if miss["warn_columns"]:
56
+ console.print(f" Warn (>20% null): [yellow]{', '.join(miss['warn_columns'])}[/yellow]")
57
+ console.print()
58
+
59
+ num_cols = self.numeric.get("numeric_col_names", [])
60
+ if num_cols:
61
+ t = Table(title="Numeric columns", box=box.SIMPLE, show_lines=False)
62
+ for h in ["Column", "Mean", "Std", "Min", "Median", "Max", "Outlier %", "Skew"]:
63
+ t.add_column(h, style="white")
64
+ for col in num_cols:
65
+ s = self.numeric["columns"].get(col, {})
66
+ if "error" in s:
67
+ continue
68
+ flag = "[yellow]⚠[/yellow] " if s.get("skewed") else ""
69
+ t.add_row(
70
+ col,
71
+ str(s.get("mean", "")),
72
+ str(s.get("std", "")),
73
+ str(s.get("min", "")),
74
+ str(s.get("median", "")),
75
+ str(s.get("max", "")),
76
+ f"{s.get('outlier_pct', 0)}%",
77
+ f"{flag}{s.get('skewness', '')}",
78
+ )
79
+ console.print(t)
80
+
81
+ cat_cols = self.categorical.get("categorical_col_names", [])
82
+ if cat_cols:
83
+ t = Table(title="Categorical columns", box=box.SIMPLE, show_lines=False)
84
+ for h in ["Column", "Unique", "Cardinality ratio", "Mode", "Mode %", "Flag"]:
85
+ t.add_column(h)
86
+ for col in cat_cols:
87
+ s = self.categorical["columns"].get(col, {})
88
+ if "error" in s:
89
+ continue
90
+ flag = ""
91
+ if s.get("likely_id_column"):
92
+ flag = "[red]likely ID[/red]"
93
+ elif s.get("high_cardinality"):
94
+ flag = "[yellow]high cardinality[/yellow]"
95
+ t.add_row(
96
+ col,
97
+ str(s.get("unique_count", "")),
98
+ str(s.get("cardinality_ratio", "")),
99
+ str(s.get("mode", ""))[:30],
100
+ f"{s.get('mode_pct', '')}%",
101
+ flag,
102
+ )
103
+ console.print(t)
104
+
105
+ strong = self.correlation.get("numeric_strong_pairs", [])
106
+ if strong:
107
+ console.print("[bold]Strong numeric correlations (|r| ≥ 0.8)[/bold]")
108
+ for pair in strong[:10]:
109
+ console.print(f" {pair['col_a']} ↔ {pair['col_b']} r={pair['pearson_r']}")
110
+ console.print()
111
+
112
+ # ──────────────────────────────────────────────────────────────────────
113
+ # HTML
114
+ # ──────────────────────────────────────────────────────────────────────
115
+
116
+ def to_html(self) -> str:
117
+ return _render_html(self)
118
+
119
+ def save_html(self, path: str = "flasheda_report.html") -> Path:
120
+ p = Path(path)
121
+ p.write_text(self.to_html(), encoding="utf-8")
122
+ print(f"HTML report saved → {p.resolve()}")
123
+ return p
124
+
125
+ # ──────────────────────────────────────────────────────────────────────
126
+ # PDF ← THE METHOD THAT WAS MISSING
127
+ # ──────────────────────────────────────────────────────────────────────
128
+
129
+ def save_pdf(self, path: str = "flasheda_report.pdf") -> Path:
130
+ """
131
+ Save a full EDA report as a PDF file.
132
+ Requires: pip install fpdf2
133
+ """
134
+ try:
135
+ from fpdf import FPDF
136
+ except ImportError:
137
+ raise ImportError(
138
+ "fpdf2 is required for PDF output. "
139
+ "Install it with: pip install fpdf2"
140
+ )
141
+
142
+ pdf = FPDF()
143
+ pdf.set_auto_page_break(auto=True, margin=15)
144
+ pdf.add_page()
145
+
146
+ orig_rows, orig_cols = self.original_shape
147
+
148
+ # ── Title bar ──────────────────────────────────────────────────────
149
+ pdf.set_font("Helvetica", "B", 20)
150
+ pdf.set_fill_color(26, 26, 46)
151
+ pdf.set_text_color(255, 255, 255)
152
+ pdf.cell(0, 14, "FlashEDA Report",
153
+ new_x="LMARGIN", new_y="NEXT", fill=True, align="C")
154
+
155
+ pdf.set_font("Helvetica", "", 9)
156
+ pdf.set_fill_color(40, 40, 70)
157
+ pdf.cell(
158
+ 0, 8,
159
+ f"Rows: {orig_rows:,} | Columns: {orig_cols} | "
160
+ f"Sample: {self.sample_size:,} | Time: {self.elapsed_seconds:.2f}s",
161
+ new_x="LMARGIN", new_y="NEXT", fill=True, align="C"
162
+ )
163
+ pdf.ln(4)
164
+ pdf.set_text_color(0, 0, 0)
165
+
166
+ # ── Warnings ───────────────────────────────────────────────────────
167
+ if self.warnings:
168
+ _pdf_section_title(pdf, "Warnings")
169
+ pdf.set_font("Helvetica", "", 9)
170
+ pdf.set_text_color(180, 100, 0)
171
+ for w in self.warnings:
172
+ pdf.cell(0, 6, f" ! {w}", new_x="LMARGIN", new_y="NEXT")
173
+ pdf.set_text_color(0, 0, 0)
174
+ pdf.ln(2)
175
+
176
+ # ── Overview ───────────────────────────────────────────────────────
177
+ _pdf_section_title(pdf, "Dataset Overview")
178
+ _pdf_kv_table(pdf, [
179
+ ["Total rows", f"{orig_rows:,}"],
180
+ ["Total columns", str(orig_cols)],
181
+ ["Sample size used", f"{self.sample_size:,}"],
182
+ ["Analysis time", f"{self.elapsed_seconds:.3f}s"],
183
+ ["Overall null %", f"{self.missing.get('overall_null_pct', 0)}%"],
184
+ ["Duplicate rows (sample)", f"{self.overview.get('duplicate_pct_in_sample', 0)}%"],
185
+ ])
186
+
187
+ # ── Missing values ─────────────────────────────────────────────────
188
+ _pdf_section_title(pdf, "Missing Values")
189
+ miss_rows = [
190
+ [col,
191
+ str(info.get("null_count", 0)),
192
+ f"{info.get('null_pct', 0)}%",
193
+ info.get("severity", "ok").upper()]
194
+ for col, info in self.missing.get("per_column", {}).items()
195
+ ]
196
+ _pdf_table(pdf, ["Column", "Null Count", "Null %", "Status"], miss_rows)
197
+
198
+ # ── Numeric columns ────────────────────────────────────────────────
199
+ num_cols = self.numeric.get("numeric_col_names", [])
200
+ if num_cols:
201
+ _pdf_section_title(pdf, "Numeric Columns")
202
+ num_rows = []
203
+ for col in num_cols:
204
+ s = self.numeric["columns"].get(col, {})
205
+ if "error" in s:
206
+ continue
207
+ num_rows.append([
208
+ col,
209
+ str(s.get("mean", "")),
210
+ str(s.get("std", "")),
211
+ str(s.get("min", "")),
212
+ str(s.get("median", "")),
213
+ str(s.get("max", "")),
214
+ f"{s.get('outlier_pct', 0)}%",
215
+ str(s.get("skewness", "")),
216
+ ])
217
+ _pdf_table(pdf,
218
+ ["Column", "Mean", "Std", "Min", "Median",
219
+ "Max", "Outlier%", "Skew"],
220
+ num_rows)
221
+
222
+ # ── Categorical columns ────────────────────────────────────────────
223
+ cat_cols = self.categorical.get("categorical_col_names", [])
224
+ if cat_cols:
225
+ _pdf_section_title(pdf, "Categorical Columns")
226
+ cat_rows = []
227
+ for col in cat_cols:
228
+ s = self.categorical["columns"].get(col, {})
229
+ if "error" in s:
230
+ continue
231
+ flag = ("LIKELY ID" if s.get("likely_id_column")
232
+ else "HIGH CARD" if s.get("high_cardinality") else "")
233
+ cat_rows.append([
234
+ col,
235
+ str(s.get("unique_count", "")),
236
+ str(s.get("cardinality_ratio", "")),
237
+ str(s.get("mode", ""))[:20],
238
+ f"{s.get('mode_pct', '')}%",
239
+ flag,
240
+ ])
241
+ _pdf_table(pdf,
242
+ ["Column", "Unique", "Cardinality",
243
+ "Mode", "Mode %", "Flag"],
244
+ cat_rows)
245
+
246
+ # ── Correlations ───────────────────────────────────────────────────
247
+ strong = self.correlation.get("numeric_strong_pairs", [])
248
+ if strong:
249
+ _pdf_section_title(pdf, "Strong Correlations (|r| >= 0.8)")
250
+ _pdf_table(pdf,
251
+ ["Column A", "Column B", "Pearson r"],
252
+ [[p["col_a"], p["col_b"], str(p["pearson_r"])]
253
+ for p in strong[:20]])
254
+
255
+ # ── Footer ─────────────────────────────────────────────────────────
256
+ pdf.ln(6)
257
+ pdf.set_font("Helvetica", "I", 8)
258
+ pdf.set_text_color(150, 150, 150)
259
+ pdf.cell(0, 6,
260
+ "Generated by FlashEDA — constant-time EDA for any dataset size",
261
+ align="C", new_x="LMARGIN", new_y="NEXT")
262
+
263
+ p = Path(path)
264
+ pdf.output(str(p))
265
+ print(f"PDF report saved -> {p.resolve()}")
266
+ return p
267
+
268
+ # ──────────────────────────────────────────────────────────────────────
269
+ # Serialisation
270
+ # ──────────────────────────────────────────────────────────────────────
271
+
272
+ def to_dict(self) -> Dict[str, Any]:
273
+ return {
274
+ "overview": self.overview,
275
+ "missing": self.missing,
276
+ "numeric": self.numeric,
277
+ "categorical": self.categorical,
278
+ "correlation": self.correlation,
279
+ "sample_size": self.sample_size,
280
+ "elapsed_seconds": self.elapsed_seconds,
281
+ "original_shape": list(self.original_shape),
282
+ "warnings": self.warnings,
283
+ }
284
+
285
+ def to_json(self, indent: int = 2) -> str:
286
+ return json.dumps(self.to_dict(), indent=indent, default=str)
287
+
288
+ def __repr__(self) -> str:
289
+ rows, cols = self.original_shape
290
+ return (
291
+ f"EDAReport(rows={rows:,}, cols={cols}, "
292
+ f"sample={self.sample_size:,}, elapsed={self.elapsed_seconds:.2f}s)"
293
+ )
294
+
295
+
296
+ # ─────────────────────────────────────────────────────────────────────────────
297
+ # PDF helpers
298
+ # ─────────────────────────────────────────────────────────────────────────────
299
+
300
+ def _pdf_section_title(pdf, title: str) -> None:
301
+ pdf.set_font("Helvetica", "B", 11)
302
+ pdf.set_fill_color(230, 235, 245)
303
+ pdf.set_text_color(26, 26, 46)
304
+ pdf.cell(0, 8, f" {title}",
305
+ new_x="LMARGIN", new_y="NEXT", fill=True)
306
+ pdf.set_text_color(0, 0, 0)
307
+ pdf.ln(2)
308
+
309
+
310
+ def _pdf_kv_table(pdf, rows: list) -> None:
311
+ pdf.set_font("Helvetica", "", 9)
312
+ col_w = 70
313
+ for key, val in rows:
314
+ pdf.set_fill_color(248, 249, 252)
315
+ pdf.cell(col_w, 7, f" {key}", border=1, fill=True)
316
+ pdf.cell(col_w, 7, f" {val}", border=1,
317
+ new_x="LMARGIN", new_y="NEXT")
318
+ pdf.ln(4)
319
+
320
+
321
+ def _pdf_table(pdf, headers: list, rows: list) -> None:
322
+ if not rows:
323
+ pdf.set_font("Helvetica", "I", 9)
324
+ pdf.cell(0, 6, " No data.",
325
+ new_x="LMARGIN", new_y="NEXT")
326
+ pdf.ln(2)
327
+ return
328
+
329
+ page_w = pdf.w - pdf.l_margin - pdf.r_margin
330
+ col_w = page_w / len(headers)
331
+
332
+ # Header
333
+ pdf.set_font("Helvetica", "B", 8)
334
+ pdf.set_fill_color(26, 26, 46)
335
+ pdf.set_text_color(255, 255, 255)
336
+ for h in headers:
337
+ pdf.cell(col_w, 7, f" {h}", border=1, fill=True)
338
+ pdf.ln()
339
+
340
+ # Rows
341
+ pdf.set_font("Helvetica", "", 8)
342
+ pdf.set_text_color(0, 0, 0)
343
+ for i, row in enumerate(rows):
344
+ pdf.set_fill_color(255, 255, 255) if i % 2 == 0 \
345
+ else pdf.set_fill_color(245, 247, 252)
346
+ for cell in row:
347
+ pdf.cell(col_w, 6, f" {str(cell)[:22]}", border=1, fill=True)
348
+ pdf.ln()
349
+
350
+ pdf.ln(4)
351
+
352
+
353
+ # ─────────────────────────────────────────────────────────────────────────────
354
+ # HTML renderer
355
+ # ─────────────────────────────────────────────────────────────────────────────
356
+
357
+ def _render_html(r: EDAReport) -> str:
358
+ num_stats = r.numeric.get("numeric_col_names", [])
359
+ cat_stats = r.categorical.get("categorical_col_names", [])
360
+ strong_corr = r.correlation.get("numeric_strong_pairs", [])
361
+ miss_per_col = r.missing.get("per_column", {})
362
+
363
+ num_rows_html = ""
364
+ for col in num_stats:
365
+ s = r.numeric["columns"].get(col, {})
366
+ if "error" in s:
367
+ continue
368
+ skew_badge = '<span class="badge warn">skewed</span>' if s.get("skewed") else ""
369
+ out_badge = '<span class="badge warn">outliers</span>' if s.get("outlier_pct", 0) > 5 else ""
370
+ num_rows_html += f"""
371
+ <tr>
372
+ <td><strong>{col}</strong></td>
373
+ <td>{s.get('mean','')}</td><td>{s.get('std','')}</td>
374
+ <td>{s.get('min','')}</td><td>{s.get('median','')}</td>
375
+ <td>{s.get('max','')}</td>
376
+ <td>{s.get('outlier_pct','')}%</td>
377
+ <td>{s.get('skewness','')} {skew_badge}</td>
378
+ <td>{out_badge}</td>
379
+ </tr>"""
380
+
381
+ cat_rows_html = ""
382
+ for col in cat_stats:
383
+ s = r.categorical["columns"].get(col, {})
384
+ if "error" in s:
385
+ continue
386
+ flag = ""
387
+ if s.get("likely_id_column"):
388
+ flag = '<span class="badge danger">likely ID</span>'
389
+ elif s.get("high_cardinality"):
390
+ flag = '<span class="badge warn">high cardinality</span>'
391
+ top = s.get("top_values", [])[:5]
392
+ top_str = " · ".join(f"{t['value']} ({t['pct']}%)" for t in top)
393
+ cat_rows_html += f"""
394
+ <tr>
395
+ <td><strong>{col}</strong></td>
396
+ <td>{s.get('unique_count','')}</td>
397
+ <td>{s.get('cardinality_ratio','')}</td>
398
+ <td>{str(s.get('mode',''))[:40]}</td>
399
+ <td>{s.get('mode_pct','')}%</td>
400
+ <td class="muted">{top_str[:80]}</td>
401
+ <td>{flag}</td>
402
+ </tr>"""
403
+
404
+ miss_rows_html = ""
405
+ for col, info in miss_per_col.items():
406
+ sev = info.get("severity", "ok")
407
+ color = {"ok": "#27ae60", "warn": "#f39c12",
408
+ "critical": "#e74c3c"}.get(sev, "#888")
409
+ bar_w = min(100, int(info.get("null_pct", 0)))
410
+ miss_rows_html += f"""
411
+ <tr>
412
+ <td>{col}</td>
413
+ <td>{info.get('null_count', 0)}</td>
414
+ <td>
415
+ <div class="bar-bg">
416
+ <div class="bar-fill" style="width:{bar_w}%;background:{color}"></div>
417
+ </div>
418
+ {info.get('null_pct', 0)}%
419
+ </td>
420
+ <td><span style="color:{color};font-weight:500">{sev}</span></td>
421
+ </tr>"""
422
+
423
+ corr_html = ""
424
+ for p in strong_corr[:15]:
425
+ corr_html += (f"<tr><td>{p['col_a']}</td>"
426
+ f"<td>{p['col_b']}</td>"
427
+ f"<td>{p['pearson_r']}</td></tr>")
428
+
429
+ warn_html = "".join(
430
+ f'<div class="warn-banner">⚠ {w}</div>'
431
+ for w in r.warnings
432
+ )
433
+
434
+ orig_rows, orig_cols = r.original_shape
435
+
436
+ return f"""<!DOCTYPE html>
437
+ <html lang="en">
438
+ <head>
439
+ <meta charset="UTF-8">
440
+ <title>FlashEDA Report</title>
441
+ <style>
442
+ *{{box-sizing:border-box;margin:0;padding:0}}
443
+ body{{font-family:system-ui,sans-serif;background:#f5f7fa;color:#222;font-size:14px}}
444
+ .container{{max-width:1100px;margin:0 auto;padding:24px 16px}}
445
+ header{{background:#1a1a2e;color:#fff;padding:24px 32px;border-radius:10px;margin-bottom:24px}}
446
+ header h1{{font-size:22px;font-weight:600;margin-bottom:6px}}
447
+ header .meta{{font-size:13px;color:#aac}}
448
+ .card{{background:#fff;border-radius:10px;padding:20px 24px;margin-bottom:20px;box-shadow:0 1px 4px rgba(0,0,0,.07)}}
449
+ .card h2{{font-size:15px;font-weight:600;margin-bottom:14px;border-bottom:1px solid #eee;padding-bottom:8px}}
450
+ table{{width:100%;border-collapse:collapse;font-size:13px}}
451
+ th{{text-align:left;padding:6px 10px;background:#f0f2f5;font-weight:500}}
452
+ td{{padding:6px 10px;border-top:1px solid #f0f0f0;vertical-align:middle}}
453
+ .badge{{display:inline-block;padding:2px 7px;border-radius:12px;font-size:11px;font-weight:600}}
454
+ .badge.warn{{background:#fff3cd;color:#856404}}
455
+ .badge.danger{{background:#f8d7da;color:#842029}}
456
+ .muted{{color:#888;font-size:12px}}
457
+ .bar-bg{{display:inline-block;width:80px;height:8px;background:#eee;border-radius:4px;vertical-align:middle;margin-right:6px}}
458
+ .bar-fill{{height:8px;border-radius:4px}}
459
+ .warn-banner{{background:#fff3cd;color:#856404;padding:10px 14px;border-radius:8px;margin-bottom:10px;font-size:13px}}
460
+ .stats-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:4px}}
461
+ .stat-box{{background:#f8f9fc;border-radius:8px;padding:14px 16px}}
462
+ .stat-box .label{{font-size:11px;color:#888;margin-bottom:4px}}
463
+ .stat-box .value{{font-size:20px;font-weight:600;color:#1a1a2e}}
464
+ </style>
465
+ </head>
466
+ <body>
467
+ <div class="container">
468
+ <header>
469
+ <h1>FlashEDA Report</h1>
470
+ <div class="meta">
471
+ {orig_rows:,} rows x {orig_cols} columns &nbsp;|&nbsp;
472
+ Analysed on {r.sample_size:,} sampled rows &nbsp;|&nbsp;
473
+ Completed in {r.elapsed_seconds:.2f}s
474
+ </div>
475
+ </header>
476
+ {warn_html}
477
+ <div class="card">
478
+ <h2>Dataset overview</h2>
479
+ <div class="stats-grid">
480
+ <div class="stat-box"><div class="label">Total rows</div><div class="value">{orig_rows:,}</div></div>
481
+ <div class="stat-box"><div class="label">Columns</div><div class="value">{orig_cols}</div></div>
482
+ <div class="stat-box"><div class="label">Sample size</div><div class="value">{r.sample_size:,}</div></div>
483
+ <div class="stat-box"><div class="label">Analysis time</div><div class="value">{r.elapsed_seconds:.2f}s</div></div>
484
+ <div class="stat-box"><div class="label">Overall null %</div><div class="value">{r.missing.get('overall_null_pct',0)}%</div></div>
485
+ <div class="stat-box"><div class="label">Duplicates (sample)</div><div class="value">{r.overview.get('duplicate_pct_in_sample',0)}%</div></div>
486
+ </div>
487
+ </div>
488
+ <div class="card">
489
+ <h2>Missing values</h2>
490
+ <table>
491
+ <thead><tr><th>Column</th><th>Null count</th><th>Null %</th><th>Status</th></tr></thead>
492
+ <tbody>{miss_rows_html}</tbody>
493
+ </table>
494
+ </div>
495
+ <div class="card">
496
+ <h2>Numeric columns</h2>
497
+ <table>
498
+ <thead><tr><th>Column</th><th>Mean</th><th>Std</th><th>Min</th><th>Median</th><th>Max</th><th>Outlier %</th><th>Skewness</th><th>Flags</th></tr></thead>
499
+ <tbody>{num_rows_html}</tbody>
500
+ </table>
501
+ </div>
502
+ <div class="card">
503
+ <h2>Categorical columns</h2>
504
+ <table>
505
+ <thead><tr><th>Column</th><th>Unique</th><th>Cardinality</th><th>Mode</th><th>Mode %</th><th>Top values</th><th>Flags</th></tr></thead>
506
+ <tbody>{cat_rows_html}</tbody>
507
+ </table>
508
+ </div>
509
+ {"" if not corr_html else f'''
510
+ <div class="card">
511
+ <h2>Strong correlations (|r| >= 0.8)</h2>
512
+ <table>
513
+ <thead><tr><th>Column A</th><th>Column B</th><th>Pearson r</th></tr></thead>
514
+ <tbody>{corr_html}</tbody>
515
+ </table>
516
+ </div>'''}
517
+ <p class="muted" style="text-align:center;margin-top:16px">
518
+ Generated by FlashEDA
519
+ </p>
520
+ </div>
521
+ </body>
522
+ </html>"""
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: flasheda
3
+ Version: 0.1.2
4
+ Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
+ Home-page: https://github.com/sjapanjots/flasheda
6
+ Author: Japanjot Singh
7
+ Author-email: sjapanjots@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pandas>=1.3.0
13
+ Requires-Dist: numpy>=1.21.0
14
+ Requires-Dist: scipy>=1.7.0
15
+ Requires-Dist: rich>=12.0.0
16
+ Requires-Dist: jinja2>=3.0.0
17
+ Requires-Dist: matplotlib>=3.4.0
18
+ Requires-Dist: fpdf2>=2.7.0
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: requires-dist
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # ⚡ FlashEDA
30
+
31
+ Constant-time Exploratory Data Analysis — one line, fixed time, any dataset size.
32
+
33
+ ## Install
34
+ ```bash
35
+ pip install flasheda
36
+ ```
37
+
38
+ ## Usage
39
+ ```python
40
+ import flasheda
41
+
42
+ report = flasheda.analyze(df) # works on 1K or 50M rows in the same time
43
+ report.show() # rich console output
44
+ report.save_html("report.html") # browser report
45
+ ```
46
+
47
+ ## How it works
48
+ FlashEDA uses **reservoir sampling** to always analyse exactly 5,000 rows,
49
+ regardless of dataset size. All analyzers run in parallel via ThreadPoolExecutor.
@@ -4,3 +4,4 @@ scipy>=1.7.0
4
4
  rich>=12.0.0
5
5
  jinja2>=3.0.0
6
6
  matplotlib>=3.4.0
7
+ fpdf2>=2.7.0
@@ -2,9 +2,13 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="flasheda",
5
- version="0.1.0",
5
+ version="0.1.2",
6
6
  description="Constant-time EDA for any dataset size — one line, fixed time.",
7
- author="Your Name",
7
+ long_description=open("README.md", encoding="utf-8").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="Japanjot Singh",
10
+ author_email="sjapanjots@gmail.com",
11
+ url="https://github.com/sjapanjots/flasheda",
8
12
  packages=find_packages(),
9
13
  python_requires=">=3.8",
10
14
  install_requires=[
@@ -14,9 +18,10 @@ setup(
14
18
  "rich>=12.0.0",
15
19
  "jinja2>=3.0.0",
16
20
  "matplotlib>=3.4.0",
21
+ "fpdf2>=2.7.0",
17
22
  ],
18
23
  classifiers=[
19
24
  "Programming Language :: Python :: 3",
20
- "License :: OSI Approved :: MIT License",
25
+ "Topic :: Scientific/Engineering :: Information Analysis",
21
26
  ],
22
27
  )
flasheda-0.1.0/PKG-INFO DELETED
@@ -1,19 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: flasheda
3
- Version: 0.1.0
4
- Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
- Author: Your Name
6
- Classifier: Programming Language :: Python :: 3
7
- Classifier: License :: OSI Approved :: MIT License
8
- Requires-Python: >=3.8
9
- Requires-Dist: pandas>=1.3.0
10
- Requires-Dist: numpy>=1.21.0
11
- Requires-Dist: scipy>=1.7.0
12
- Requires-Dist: rich>=12.0.0
13
- Requires-Dist: jinja2>=3.0.0
14
- Requires-Dist: matplotlib>=3.4.0
15
- Dynamic: author
16
- Dynamic: classifier
17
- Dynamic: requires-dist
18
- Dynamic: requires-python
19
- Dynamic: summary
@@ -1,348 +0,0 @@
1
- """
2
- flasheda.report
3
- ~~~~~~~~~~~~~~~
4
- EDAReport: the single object returned by flasheda.analyze().
5
- Supports:
6
- - .show() → rich console table
7
- - .to_html() → self-contained HTML report
8
- - .to_dict() → plain dict
9
- - .to_json() → JSON string
10
- - .save_html() → write HTML to disk
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- import time
17
- from dataclasses import dataclass, field
18
- from pathlib import Path
19
- from typing import Any, Dict, Optional
20
-
21
-
22
- @dataclass
23
- class EDAReport:
24
- overview: Dict[str, Any]
25
- missing: Dict[str, Any]
26
- numeric: Dict[str, Any]
27
- categorical: Dict[str, Any]
28
- correlation: Dict[str, Any]
29
- sample_size: int
30
- elapsed_seconds: float
31
- original_shape: tuple
32
- warnings: list[str] = field(default_factory=list)
33
-
34
- # ──────────────────────────────────────────────────────────────────────
35
- # Console output
36
- # ──────────────────────────────────────────────────────────────────────
37
-
38
- def show(self) -> None:
39
- """Print a rich summary to the console."""
40
- from rich.console import Console
41
- from rich.table import Table
42
- from rich import box
43
- from rich.panel import Panel
44
-
45
- console = Console()
46
-
47
- # ── Header ──
48
- orig_rows = self.original_shape[0]
49
- ncols = self.original_shape[1]
50
- console.print(
51
- Panel(
52
- f"[bold cyan]FlashEDA Report[/bold cyan]\n"
53
- f"Dataset: [green]{orig_rows:,}[/green] rows × [green]{ncols}[/green] cols "
54
- f"| Analysed on [yellow]{self.sample_size:,}[/yellow] sampled rows "
55
- f"| ⏱ [white]{self.elapsed_seconds:.2f}s[/white]",
56
- expand=False,
57
- )
58
- )
59
-
60
- # ── Warnings ──
61
- if self.warnings:
62
- for w in self.warnings:
63
- console.print(f" [bold yellow]⚠ {w}[/bold yellow]")
64
- console.print()
65
-
66
- # ── Missing values ──
67
- miss = self.missing
68
- console.print(f"[bold]Missing values[/bold] overall: [red]{miss['overall_null_pct']}%[/red] "
69
- f"| rows with any null: [red]{miss['rows_with_any_null_pct']}%[/red]")
70
- if miss["critical_columns"]:
71
- console.print(f" Critical (>50% null): [red]{', '.join(miss['critical_columns'])}[/red]")
72
- if miss["warn_columns"]:
73
- console.print(f" Warn (>20% null): [yellow]{', '.join(miss['warn_columns'])}[/yellow]")
74
- console.print()
75
-
76
- # ── Numeric summary table ──
77
- num_cols = self.numeric.get("numeric_col_names", [])
78
- if num_cols:
79
- t = Table(title="Numeric columns", box=box.SIMPLE, show_lines=False)
80
- for h in ["Column", "Mean", "Std", "Min", "Median", "Max", "Outlier %", "Skew"]:
81
- t.add_column(h, style="white")
82
- for col in num_cols:
83
- s = self.numeric["columns"].get(col, {})
84
- if "error" in s:
85
- continue
86
- flag = "[yellow]⚠[/yellow] " if s.get("skewed") else ""
87
- t.add_row(
88
- col,
89
- str(s.get("mean", "")),
90
- str(s.get("std", "")),
91
- str(s.get("min", "")),
92
- str(s.get("median", "")),
93
- str(s.get("max", "")),
94
- f"{s.get('outlier_pct', 0)}%",
95
- f"{flag}{s.get('skewness', '')}",
96
- )
97
- console.print(t)
98
-
99
- # ── Categorical summary table ──
100
- cat_cols = self.categorical.get("categorical_col_names", [])
101
- if cat_cols:
102
- t = Table(title="Categorical columns", box=box.SIMPLE, show_lines=False)
103
- for h in ["Column", "Unique", "Cardinality ratio", "Mode", "Mode %", "Flag"]:
104
- t.add_column(h)
105
- for col in cat_cols:
106
- s = self.categorical["columns"].get(col, {})
107
- if "error" in s:
108
- continue
109
- flag = ""
110
- if s.get("likely_id_column"):
111
- flag = "[red]likely ID[/red]"
112
- elif s.get("high_cardinality"):
113
- flag = "[yellow]high cardinality[/yellow]"
114
- t.add_row(
115
- col,
116
- str(s.get("unique_count", "")),
117
- str(s.get("cardinality_ratio", "")),
118
- str(s.get("mode", ""))[:30],
119
- f"{s.get('mode_pct', '')}%",
120
- flag,
121
- )
122
- console.print(t)
123
-
124
- # ── Strong correlations ──
125
- strong = self.correlation.get("numeric_strong_pairs", [])
126
- if strong:
127
- console.print("[bold]Strong numeric correlations (|r| ≥ 0.8)[/bold]")
128
- for pair in strong[:10]:
129
- console.print(f" {pair['col_a']} ↔ {pair['col_b']} r={pair['pearson_r']}")
130
- console.print()
131
-
132
- # ──────────────────────────────────────────────────────────────────────
133
- # HTML report
134
- # ──────────────────────────────────────────────────────────────────────
135
-
136
- def to_html(self) -> str:
137
- """Return a self-contained HTML report string."""
138
- return _render_html(self)
139
-
140
- def save_html(self, path: str = "flasheda_report.html") -> Path:
141
- """Save the HTML report to disk and return the path."""
142
- p = Path(path)
143
- p.write_text(self.to_html(), encoding="utf-8")
144
- return p
145
-
146
- # ──────────────────────────────────────────────────────────────────────
147
- # Serialisation
148
- # ──────────────────────────────────────────────────────────────────────
149
-
150
- def to_dict(self) -> Dict[str, Any]:
151
- return {
152
- "overview": self.overview,
153
- "missing": self.missing,
154
- "numeric": self.numeric,
155
- "categorical": self.categorical,
156
- "correlation": self.correlation,
157
- "sample_size": self.sample_size,
158
- "elapsed_seconds": self.elapsed_seconds,
159
- "original_shape": list(self.original_shape),
160
- "warnings": self.warnings,
161
- }
162
-
163
- def to_json(self, indent: int = 2) -> str:
164
- return json.dumps(self.to_dict(), indent=indent, default=str)
165
-
166
- def __repr__(self) -> str:
167
- rows, cols = self.original_shape
168
- return (
169
- f"EDAReport(rows={rows:,}, cols={cols}, "
170
- f"sample={self.sample_size:,}, elapsed={self.elapsed_seconds:.2f}s)"
171
- )
172
-
173
-
174
- # ─────────────────────────────────────────────────────────────────────────────
175
- # HTML renderer
176
- # ─────────────────────────────────────────────────────────────────────────────
177
-
178
- def _render_html(r: EDAReport) -> str:
179
- num_stats = r.numeric.get("numeric_col_names", [])
180
- cat_stats = r.categorical.get("categorical_col_names", [])
181
- strong_corr = r.correlation.get("numeric_strong_pairs", [])
182
- miss_per_col = r.missing.get("per_column", {})
183
-
184
- # Build numeric rows
185
- num_rows_html = ""
186
- for col in num_stats:
187
- s = r.numeric["columns"].get(col, {})
188
- if "error" in s:
189
- continue
190
- skew_badge = '<span class="badge warn">skewed</span>' if s.get("skewed") else ""
191
- out_badge = '<span class="badge warn">outliers</span>' if s.get("outlier_pct", 0) > 5 else ""
192
- num_rows_html += f"""
193
- <tr>
194
- <td><strong>{col}</strong></td>
195
- <td>{s.get('mean', '')}</td><td>{s.get('std', '')}</td>
196
- <td>{s.get('min', '')}</td><td>{s.get('median', '')}</td><td>{s.get('max', '')}</td>
197
- <td>{s.get('outlier_pct', '')}%</td>
198
- <td>{s.get('skewness', '')} {skew_badge}</td>
199
- <td>{out_badge}</td>
200
- </tr>"""
201
-
202
- # Build categorical rows
203
- cat_rows_html = ""
204
- for col in cat_stats:
205
- s = r.categorical["columns"].get(col, {})
206
- if "error" in s:
207
- continue
208
- flag = ""
209
- if s.get("likely_id_column"):
210
- flag = '<span class="badge danger">likely ID</span>'
211
- elif s.get("high_cardinality"):
212
- flag = '<span class="badge warn">high cardinality</span>'
213
- top = s.get("top_values", [])[:5]
214
- top_str = " · ".join(f"{t['value']} ({t['pct']}%)" for t in top)
215
- cat_rows_html += f"""
216
- <tr>
217
- <td><strong>{col}</strong></td>
218
- <td>{s.get('unique_count', '')}</td>
219
- <td>{s.get('cardinality_ratio', '')}</td>
220
- <td>{s.get('mode', '')[:40]}</td>
221
- <td>{s.get('mode_pct', '')}%</td>
222
- <td class="muted">{top_str[:80]}</td>
223
- <td>{flag}</td>
224
- </tr>"""
225
-
226
- # Missing table
227
- miss_rows_html = ""
228
- for col, info in miss_per_col.items():
229
- sev = info.get("severity", "ok")
230
- color = {"ok": "#27ae60", "warn": "#f39c12", "critical": "#e74c3c"}.get(sev, "#888")
231
- bar_w = min(100, int(info.get("null_pct", 0)))
232
- miss_rows_html += f"""
233
- <tr>
234
- <td>{col}</td>
235
- <td>{info.get('null_count', 0)}</td>
236
- <td>
237
- <div class="bar-bg">
238
- <div class="bar-fill" style="width:{bar_w}%;background:{color}"></div>
239
- </div>
240
- {info.get('null_pct', 0)}%
241
- </td>
242
- <td><span style="color:{color};font-weight:500">{sev}</span></td>
243
- </tr>"""
244
-
245
- # Correlation rows
246
- corr_html = ""
247
- for p in strong_corr[:15]:
248
- corr_html += f"<tr><td>{p['col_a']}</td><td>{p['col_b']}</td><td>{p['pearson_r']}</td></tr>"
249
-
250
- # Warnings
251
- warn_html = ""
252
- for w in r.warnings:
253
- warn_html += f'<div class="warn-banner">⚠ {w}</div>'
254
-
255
- orig_rows, orig_cols = r.original_shape
256
-
257
- return f"""<!DOCTYPE html>
258
- <html lang="en">
259
- <head>
260
- <meta charset="UTF-8">
261
- <title>FlashEDA Report</title>
262
- <style>
263
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
264
- body {{ font-family: system-ui, sans-serif; background: #f5f7fa; color: #222; font-size: 14px; }}
265
- .container {{ max-width: 1100px; margin: 0 auto; padding: 24px 16px; }}
266
- header {{ background: #1a1a2e; color: #fff; padding: 24px 32px; border-radius: 10px; margin-bottom: 24px; }}
267
- header h1 {{ font-size: 22px; font-weight: 600; margin-bottom: 6px; }}
268
- header .meta {{ font-size: 13px; color: #aac; }}
269
- .card {{ background: #fff; border-radius: 10px; padding: 20px 24px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
270
- .card h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 14px; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
271
- table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
272
- th {{ text-align: left; padding: 6px 10px; background: #f0f2f5; font-weight: 500; }}
273
- td {{ padding: 6px 10px; border-top: 1px solid #f0f0f0; vertical-align: middle; }}
274
- .badge {{ display: inline-block; padding: 2px 7px; border-radius: 12px; font-size: 11px; font-weight: 600; }}
275
- .badge.warn {{ background: #fff3cd; color: #856404; }}
276
- .badge.danger {{ background: #f8d7da; color: #842029; }}
277
- .muted {{ color: #888; font-size: 12px; }}
278
- .bar-bg {{ display: inline-block; width: 80px; height: 8px; background: #eee; border-radius: 4px; vertical-align: middle; margin-right: 6px; }}
279
- .bar-fill {{ height: 8px; border-radius: 4px; }}
280
- .warn-banner {{ background: #fff3cd; color: #856404; padding: 10px 14px; border-radius: 8px; margin-bottom: 10px; font-size: 13px; }}
281
- .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 4px; }}
282
- .stat-box {{ background: #f8f9fc; border-radius: 8px; padding: 14px 16px; }}
283
- .stat-box .label {{ font-size: 11px; color: #888; margin-bottom: 4px; }}
284
- .stat-box .value {{ font-size: 20px; font-weight: 600; color: #1a1a2e; }}
285
- </style>
286
- </head>
287
- <body>
288
- <div class="container">
289
- <header>
290
- <h1>⚡ FlashEDA Report</h1>
291
- <div class="meta">
292
- {orig_rows:,} rows × {orig_cols} columns &nbsp;|&nbsp;
293
- Analysed on {r.sample_size:,} sampled rows &nbsp;|&nbsp;
294
- Completed in {r.elapsed_seconds:.2f}s
295
- </div>
296
- </header>
297
-
298
- {warn_html}
299
-
300
- <div class="card">
301
- <h2>Dataset overview</h2>
302
- <div class="stats-grid">
303
- <div class="stat-box"><div class="label">Total rows</div><div class="value">{orig_rows:,}</div></div>
304
- <div class="stat-box"><div class="label">Columns</div><div class="value">{orig_cols}</div></div>
305
- <div class="stat-box"><div class="label">Sample size</div><div class="value">{r.sample_size:,}</div></div>
306
- <div class="stat-box"><div class="label">Analysis time</div><div class="value">{r.elapsed_seconds:.2f}s</div></div>
307
- <div class="stat-box"><div class="label">Overall null %</div><div class="value">{r.missing.get('overall_null_pct', 0)}%</div></div>
308
- <div class="stat-box"><div class="label">Duplicate rows (sample)</div><div class="value">{r.overview.get('duplicate_pct_in_sample', 0)}%</div></div>
309
- </div>
310
- </div>
311
-
312
- <div class="card">
313
- <h2>Missing values</h2>
314
- <table>
315
- <thead><tr><th>Column</th><th>Null count</th><th>Null %</th><th>Status</th></tr></thead>
316
- <tbody>{miss_rows_html}</tbody>
317
- </table>
318
- </div>
319
-
320
- <div class="card">
321
- <h2>Numeric columns</h2>
322
- <table>
323
- <thead><tr><th>Column</th><th>Mean</th><th>Std</th><th>Min</th><th>Median</th><th>Max</th><th>Outlier %</th><th>Skewness</th><th>Flags</th></tr></thead>
324
- <tbody>{num_rows_html}</tbody>
325
- </table>
326
- </div>
327
-
328
- <div class="card">
329
- <h2>Categorical columns</h2>
330
- <table>
331
- <thead><tr><th>Column</th><th>Unique</th><th>Cardinality ratio</th><th>Mode</th><th>Mode %</th><th>Top values</th><th>Flags</th></tr></thead>
332
- <tbody>{cat_rows_html}</tbody>
333
- </table>
334
- </div>
335
-
336
- {"" if not corr_html else f'''
337
- <div class="card">
338
- <h2>Strong correlations (|r| ≥ 0.8)</h2>
339
- <table>
340
- <thead><tr><th>Column A</th><th>Column B</th><th>Pearson r</th></tr></thead>
341
- <tbody>{corr_html}</tbody>
342
- </table>
343
- </div>'''}
344
-
345
- <p class="muted" style="text-align:center;margin-top:16px">Generated by FlashEDA — constant-time EDA for any dataset size</p>
346
- </div>
347
- </body>
348
- </html>"""
@@ -1,19 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: flasheda
3
- Version: 0.1.0
4
- Summary: Constant-time EDA for any dataset size — one line, fixed time.
5
- Author: Your Name
6
- Classifier: Programming Language :: Python :: 3
7
- Classifier: License :: OSI Approved :: MIT License
8
- Requires-Python: >=3.8
9
- Requires-Dist: pandas>=1.3.0
10
- Requires-Dist: numpy>=1.21.0
11
- Requires-Dist: scipy>=1.7.0
12
- Requires-Dist: rich>=12.0.0
13
- Requires-Dist: jinja2>=3.0.0
14
- Requires-Dist: matplotlib>=3.4.0
15
- Dynamic: author
16
- Dynamic: classifier
17
- Dynamic: requires-dist
18
- Dynamic: requires-python
19
- Dynamic: summary
File without changes
File without changes
File without changes
File without changes