tablofy 1.0.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.
- tablofy/__init__.py +28 -0
- tablofy/analytics/__init__.py +1 -0
- tablofy/analytics/insights.py +130 -0
- tablofy/analytics/outliers.py +81 -0
- tablofy/analytics/stats.py +115 -0
- tablofy/cleaning/__init__.py +1 -0
- tablofy/cleaning/cleaner.py +132 -0
- tablofy/cleaning/report.py +29 -0
- tablofy/core/__init__.py +1 -0
- tablofy/core/config.py +11 -0
- tablofy/core/errors.py +25 -0
- tablofy/core/frame.py +641 -0
- tablofy/core/loader.py +62 -0
- tablofy/exploration/__init__.py +1 -0
- tablofy/exploration/profile.py +59 -0
- tablofy/exploration/schema.py +53 -0
- tablofy/exploration/summary.py +32 -0
- tablofy/reports/__init__.py +1 -0
- tablofy/reports/excel.py +65 -0
- tablofy/reports/html.py +71 -0
- tablofy/sql/__init__.py +1 -0
- tablofy/sql/duckdb_query.py +77 -0
- tablofy/transform/__init__.py +1 -0
- tablofy/transform/group.py +71 -0
- tablofy/transform/operations.py +213 -0
- tablofy/transform/safe_filter.py +76 -0
- tablofy/utils/__init__.py +1 -0
- tablofy/utils/formatting.py +50 -0
- tablofy/utils/lazy_imports.py +23 -0
- tablofy/utils/validation.py +70 -0
- tablofy/visualization/__init__.py +1 -0
- tablofy/visualization/charts.py +130 -0
- tablofy/visualization/smart_chart.py +72 -0
- tablofy-1.0.0.dist-info/METADATA +242 -0
- tablofy-1.0.0.dist-info/RECORD +38 -0
- tablofy-1.0.0.dist-info/WHEEL +5 -0
- tablofy-1.0.0.dist-info/licenses/LICENSE +21 -0
- tablofy-1.0.0.dist-info/top_level.txt +1 -0
tablofy/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tablofy — A beginner-friendly data analytics library."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from tablofy.core.errors import (
|
|
6
|
+
TablofyColumnError,
|
|
7
|
+
TablofyDataError,
|
|
8
|
+
TablofyError,
|
|
9
|
+
TablofyFileError,
|
|
10
|
+
)
|
|
11
|
+
from tablofy.core.frame import TablofyFrame
|
|
12
|
+
from tablofy.core.loader import load
|
|
13
|
+
|
|
14
|
+
ColumnNotFoundError = TablofyColumnError
|
|
15
|
+
EmptyTableError = TablofyDataError
|
|
16
|
+
FileFormatError = TablofyFileError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"TablofyFrame",
|
|
20
|
+
"load",
|
|
21
|
+
"TablofyError",
|
|
22
|
+
"TablofyFileError",
|
|
23
|
+
"TablofyColumnError",
|
|
24
|
+
"TablofyDataError",
|
|
25
|
+
"ColumnNotFoundError",
|
|
26
|
+
"EmptyTableError",
|
|
27
|
+
"FileFormatError",
|
|
28
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analytics: insights, statistics, outlier detection."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Rule-based insight generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from tablofy.core.frame import TablofyFrame
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Insights:
|
|
12
|
+
"""Generates deterministic, rule-based observations about a dataset."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, frame: TablofyFrame) -> None:
|
|
15
|
+
self._frame = frame
|
|
16
|
+
self._df = frame._df
|
|
17
|
+
|
|
18
|
+
def generate(self) -> list[str]:
|
|
19
|
+
"""Return a list of human-readable insight strings.
|
|
20
|
+
|
|
21
|
+
Checks performed
|
|
22
|
+
-----------------
|
|
23
|
+
- Dataset dimensions
|
|
24
|
+
- Missing values
|
|
25
|
+
- Duplicate rows
|
|
26
|
+
- Strong correlations
|
|
27
|
+
- High-cardinality columns
|
|
28
|
+
- Low-cardinality categorical columns
|
|
29
|
+
- Highest/lowest numeric values
|
|
30
|
+
- Outlier hints
|
|
31
|
+
"""
|
|
32
|
+
insights: list[str] = []
|
|
33
|
+
df = self._df
|
|
34
|
+
|
|
35
|
+
rows, cols = df.shape
|
|
36
|
+
insights.append(f"Dataset has {rows} rows and {cols} columns.")
|
|
37
|
+
|
|
38
|
+
# Missing values
|
|
39
|
+
missing_counts = df.isna().sum()
|
|
40
|
+
total_missing = int(missing_counts.sum())
|
|
41
|
+
if total_missing:
|
|
42
|
+
pct = total_missing / df.size * 100
|
|
43
|
+
insights.append(
|
|
44
|
+
f"Found {total_missing} missing cell(s) ({pct:.1f}% of data)."
|
|
45
|
+
)
|
|
46
|
+
cols_with_missing = [c for c in df.columns if missing_counts[c] > 0]
|
|
47
|
+
insights.append(
|
|
48
|
+
f"Columns with missing values: {cols_with_missing}."
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
insights.append("No missing values detected.")
|
|
52
|
+
|
|
53
|
+
# Duplicate rows
|
|
54
|
+
dup_count = int(df.duplicated().sum())
|
|
55
|
+
if dup_count:
|
|
56
|
+
dup_pct = dup_count / rows * 100
|
|
57
|
+
insights.append(
|
|
58
|
+
f"Found {dup_count} duplicate row(s) ({dup_pct:.1f}% of rows)."
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
insights.append("No duplicate rows found.")
|
|
62
|
+
|
|
63
|
+
# Strong correlations
|
|
64
|
+
numeric = df.select_dtypes(include="number")
|
|
65
|
+
if numeric.shape[1] >= 2:
|
|
66
|
+
corr = numeric.corr().abs()
|
|
67
|
+
strong: list[str] = []
|
|
68
|
+
for i in range(len(corr.columns)):
|
|
69
|
+
for j in range(i + 1, len(corr.columns)):
|
|
70
|
+
val = corr.iloc[i, j]
|
|
71
|
+
if val >= 0.7:
|
|
72
|
+
strong.append(
|
|
73
|
+
f"{corr.columns[i]} — {corr.columns[j]} "
|
|
74
|
+
f"(r = {corr.iloc[i, j]:.2f})"
|
|
75
|
+
)
|
|
76
|
+
if strong:
|
|
77
|
+
insights.append(
|
|
78
|
+
f"Strong correlations detected ({len(strong)}): "
|
|
79
|
+
f"{'; '.join(strong)}."
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
insights.append(
|
|
83
|
+
"No strong correlations (|r| >= 0.7) found between numeric columns."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# High-cardinality columns
|
|
87
|
+
high_card = [
|
|
88
|
+
c for c in df.columns
|
|
89
|
+
if df[c].nunique() > 50 and df[c].nunique() == len(df)
|
|
90
|
+
]
|
|
91
|
+
if high_card:
|
|
92
|
+
insights.append(
|
|
93
|
+
f"High-cardinality columns (unique ID-like): {high_card}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Low-cardinality categorical columns
|
|
97
|
+
obj_cols = df.select_dtypes(include=["object", "category"]).columns
|
|
98
|
+
low_card = [c for c in obj_cols if 2 <= df[c].nunique() <= 10]
|
|
99
|
+
if low_card:
|
|
100
|
+
insights.append(
|
|
101
|
+
f"Low-cardinality categorical columns ({len(low_card)}): "
|
|
102
|
+
f"{low_card}."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Highest / lowest numeric
|
|
106
|
+
if numeric.shape[1] >= 1:
|
|
107
|
+
for col in numeric.columns:
|
|
108
|
+
max_val = df[col].max()
|
|
109
|
+
min_val = df[col].min()
|
|
110
|
+
insights.append(
|
|
111
|
+
f"{col}: min = {min_val}, max = {max_val}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Outlier hints (IQR-based)
|
|
115
|
+
if numeric.shape[1] >= 1:
|
|
116
|
+
for col in numeric.columns:
|
|
117
|
+
q1 = df[col].quantile(0.25)
|
|
118
|
+
q3 = df[col].quantile(0.75)
|
|
119
|
+
iqr = q3 - q1
|
|
120
|
+
lower = q1 - 1.5 * iqr
|
|
121
|
+
upper = q3 + 1.5 * iqr
|
|
122
|
+
outlier_count = int(((df[col] < lower) | (df[col] > upper)).sum())
|
|
123
|
+
if outlier_count:
|
|
124
|
+
pct = outlier_count / rows * 100
|
|
125
|
+
insights.append(
|
|
126
|
+
f"{col}: {outlier_count} potential outlier(s) "
|
|
127
|
+
f"({pct:.1f}% of rows)."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return insights
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Outlier detection using IQR and z-score methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from tablofy.core.errors import TablofyColumnError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tablofy.core.frame import TablofyFrame
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutlierDetector:
|
|
16
|
+
"""Detects outliers in numeric columns."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, frame: TablofyFrame) -> None:
|
|
19
|
+
self._frame = frame
|
|
20
|
+
self._df = frame._df
|
|
21
|
+
|
|
22
|
+
def iqr_outliers(
|
|
23
|
+
self, column: str, multiplier: float = 1.5
|
|
24
|
+
) -> pd.DataFrame:
|
|
25
|
+
"""Return rows where *column* values are IQR outliers.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
column : str
|
|
30
|
+
Numeric column to check.
|
|
31
|
+
multiplier : float
|
|
32
|
+
IQR multiplier for the fence (default 1.5).
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
pd.DataFrame
|
|
37
|
+
Rows where the column value is outside the IQR fences.
|
|
38
|
+
Includes a ``_outlier_distance`` column.
|
|
39
|
+
"""
|
|
40
|
+
if column not in self._df.columns:
|
|
41
|
+
raise TablofyColumnError(
|
|
42
|
+
f"Column {column!r} not found. "
|
|
43
|
+
f"Available: {list(self._df.columns)}"
|
|
44
|
+
)
|
|
45
|
+
if not pd.api.types.is_numeric_dtype(self._df[column]):
|
|
46
|
+
raise TablofyColumnError(
|
|
47
|
+
f"Column {column!r} is not numeric."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
q1 = self._df[column].quantile(0.25)
|
|
51
|
+
q3 = self._df[column].quantile(0.75)
|
|
52
|
+
iqr = q3 - q1
|
|
53
|
+
lower = q1 - multiplier * iqr
|
|
54
|
+
upper = q3 + multiplier * iqr
|
|
55
|
+
mask = (self._df[column] < lower) | (self._df[column] > upper)
|
|
56
|
+
result = self._df[mask].copy()
|
|
57
|
+
result["_outlier_distance"] = (self._df.loc[mask, column] - q3).abs()
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def zscore_outliers(
|
|
61
|
+
self, column: str, threshold: float = 3.0
|
|
62
|
+
) -> pd.DataFrame:
|
|
63
|
+
"""Return rows where *column* has a z-score beyond *threshold*."""
|
|
64
|
+
if column not in self._df.columns:
|
|
65
|
+
raise TablofyColumnError(
|
|
66
|
+
f"Column {column!r} not found. "
|
|
67
|
+
f"Available: {list(self._df.columns)}"
|
|
68
|
+
)
|
|
69
|
+
if not pd.api.types.is_numeric_dtype(self._df[column]):
|
|
70
|
+
raise TablofyColumnError(
|
|
71
|
+
f"Column {column!r} is not numeric."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
mean = self._df[column].mean()
|
|
75
|
+
std = self._df[column].std()
|
|
76
|
+
if std == 0:
|
|
77
|
+
return self._df.iloc[:0].copy()
|
|
78
|
+
zscores = (self._df[column] - mean).abs() / std
|
|
79
|
+
result = self._df[zscores > threshold].copy()
|
|
80
|
+
result["_zscore"] = zscores[zscores > threshold]
|
|
81
|
+
return result
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Statistical helpers for TablofyFrame."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from tablofy.core.errors import TablofyColumnError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tablofy.core.frame import TablofyFrame
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Stats:
|
|
16
|
+
"""Statistical operations on a TablofyFrame.
|
|
17
|
+
|
|
18
|
+
Accessed via ``data.stats``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, frame: TablofyFrame) -> None:
|
|
22
|
+
self._frame = frame
|
|
23
|
+
self._df = frame._df
|
|
24
|
+
|
|
25
|
+
def _check_col(self, column: str) -> None:
|
|
26
|
+
if column not in self._df.columns:
|
|
27
|
+
raise TablofyColumnError(
|
|
28
|
+
f"Column {column!r} not found. "
|
|
29
|
+
f"Available: {list(self._df.columns)}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def _check_numeric(self, column: str) -> None:
|
|
33
|
+
self._check_col(column)
|
|
34
|
+
if not pd.api.types.is_numeric_dtype(self._df[column]):
|
|
35
|
+
raise TablofyColumnError(
|
|
36
|
+
f"Column {column!r} is not numeric."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def describe(self, **kwargs: Any) -> pd.DataFrame:
|
|
40
|
+
"""Return descriptive statistics for all columns."""
|
|
41
|
+
return self._df.describe(include="all", **kwargs)
|
|
42
|
+
|
|
43
|
+
def correlation(self, **kwargs: Any) -> pd.DataFrame:
|
|
44
|
+
"""Return the correlation matrix of numeric columns."""
|
|
45
|
+
return self._df.select_dtypes(include="number").corr(**kwargs)
|
|
46
|
+
|
|
47
|
+
def covariance(self, **kwargs: Any) -> pd.DataFrame:
|
|
48
|
+
"""Return the covariance matrix of numeric columns."""
|
|
49
|
+
return self._df.select_dtypes(include="number").cov(**kwargs)
|
|
50
|
+
|
|
51
|
+
def outliers(
|
|
52
|
+
self,
|
|
53
|
+
column: str,
|
|
54
|
+
multiplier: float = 1.5,
|
|
55
|
+
return_indices: bool = False,
|
|
56
|
+
) -> pd.DataFrame | dict:
|
|
57
|
+
"""Detect outliers in *column* using the IQR method.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
column : str
|
|
62
|
+
Numeric column to check.
|
|
63
|
+
multiplier : float
|
|
64
|
+
IQR multiplier for the fence (default 1.5).
|
|
65
|
+
return_indices : bool
|
|
66
|
+
If True, return a dict with ``count`` and ``indices``.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
pd.DataFrame or dict
|
|
71
|
+
"""
|
|
72
|
+
from tablofy.analytics.outliers import OutlierDetector
|
|
73
|
+
|
|
74
|
+
result = OutlierDetector(self._frame).iqr_outliers(column, multiplier)
|
|
75
|
+
if return_indices:
|
|
76
|
+
return {
|
|
77
|
+
"count": len(result),
|
|
78
|
+
"indices": list(result.index),
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def mean(self, column: str) -> float:
|
|
83
|
+
"""Return the mean of *column*."""
|
|
84
|
+
self._check_numeric(column)
|
|
85
|
+
return float(self._df[column].mean())
|
|
86
|
+
|
|
87
|
+
def median(self, column: str) -> float:
|
|
88
|
+
"""Return the median of *column*."""
|
|
89
|
+
self._check_numeric(column)
|
|
90
|
+
return float(self._df[column].median())
|
|
91
|
+
|
|
92
|
+
def std(self, column: str) -> float:
|
|
93
|
+
"""Return the standard deviation of *column*."""
|
|
94
|
+
self._check_numeric(column)
|
|
95
|
+
return float(self._df[column].std())
|
|
96
|
+
|
|
97
|
+
def min(self, column: str) -> float:
|
|
98
|
+
"""Return the minimum of *column*."""
|
|
99
|
+
self._check_numeric(column)
|
|
100
|
+
return float(self._df[column].min())
|
|
101
|
+
|
|
102
|
+
def max(self, column: str) -> float:
|
|
103
|
+
"""Return the maximum of *column*."""
|
|
104
|
+
self._check_numeric(column)
|
|
105
|
+
return float(self._df[column].max())
|
|
106
|
+
|
|
107
|
+
def quantile(self, column: str, q: float) -> float:
|
|
108
|
+
"""Return the *q*-th quantile of *column*."""
|
|
109
|
+
self._check_numeric(column)
|
|
110
|
+
return float(self._df[column].quantile(q))
|
|
111
|
+
|
|
112
|
+
def value_counts(self, column: str) -> pd.Series:
|
|
113
|
+
"""Return frequency counts for *column*."""
|
|
114
|
+
self._check_col(column)
|
|
115
|
+
return self._df[column].value_counts()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data cleaning: cleaner and cleaning report."""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Data cleaning operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from tablofy.utils.formatting import Formatter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tablofy.core.frame import TablofyFrame
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Cleaner:
|
|
16
|
+
"""Performs cleaning operations on a TablofyFrame in-place."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, frame: TablofyFrame) -> None:
|
|
19
|
+
self._frame = frame
|
|
20
|
+
self.actions: list[dict[str, Any]] = []
|
|
21
|
+
|
|
22
|
+
def run(
|
|
23
|
+
self,
|
|
24
|
+
duplicates: bool = True,
|
|
25
|
+
missing: str | dict = "smart",
|
|
26
|
+
columns: str | bool = "snake_case",
|
|
27
|
+
dates: bool = True,
|
|
28
|
+
text: bool = True,
|
|
29
|
+
) -> TablofyFrame:
|
|
30
|
+
"""Execute the cleaning pipeline and return the frame."""
|
|
31
|
+
self.actions = []
|
|
32
|
+
df = self._frame._df
|
|
33
|
+
|
|
34
|
+
# 1. Remove duplicate rows
|
|
35
|
+
if duplicates:
|
|
36
|
+
before = len(df)
|
|
37
|
+
df.drop_duplicates(inplace=True)
|
|
38
|
+
removed = before - len(df)
|
|
39
|
+
if removed:
|
|
40
|
+
self.actions.append({
|
|
41
|
+
"action": "duplicates",
|
|
42
|
+
"message": f"Removed {removed} duplicate row(s).",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
# 2. Remove fully empty columns
|
|
46
|
+
empty_cols = [col for col in df.columns if df[col].isna().all()]
|
|
47
|
+
if empty_cols:
|
|
48
|
+
df.drop(columns=empty_cols, inplace=True)
|
|
49
|
+
self.actions.append({
|
|
50
|
+
"action": "empty_columns",
|
|
51
|
+
"message": f"Removed {len(empty_cols)} fully empty column(s): {empty_cols}.",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
# 3. Clean column names to snake_case
|
|
55
|
+
if columns == "snake_case":
|
|
56
|
+
renamed = {}
|
|
57
|
+
for col in df.columns:
|
|
58
|
+
clean = Formatter.to_snake_case(col)
|
|
59
|
+
if clean != col:
|
|
60
|
+
renamed[col] = clean
|
|
61
|
+
if renamed:
|
|
62
|
+
df.rename(columns=renamed, inplace=True)
|
|
63
|
+
self.actions.append({
|
|
64
|
+
"action": "column_names",
|
|
65
|
+
"message": f"Renamed {len(renamed)} column(s) to snake_case.",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# 4. Strip whitespace from text columns
|
|
69
|
+
if text:
|
|
70
|
+
stripped_cols = []
|
|
71
|
+
text_cols = df.select_dtypes(include=["object"]).columns
|
|
72
|
+
for col in text_cols:
|
|
73
|
+
before_strip = df[col].astype(str).str.strip()
|
|
74
|
+
df[col] = df[col].astype(str).str.strip()
|
|
75
|
+
df[col] = df[col].replace(["nan", "None", ""], None)
|
|
76
|
+
if not df[col].equals(before_strip):
|
|
77
|
+
stripped_cols.append(col)
|
|
78
|
+
if stripped_cols:
|
|
79
|
+
self.actions.append({
|
|
80
|
+
"action": "whitespace",
|
|
81
|
+
"message": f"Stripped whitespace from {len(stripped_cols)} text column(s).",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
# 5. Smart fill missing values
|
|
85
|
+
if missing == "smart":
|
|
86
|
+
filled_cols = []
|
|
87
|
+
for col in df.columns:
|
|
88
|
+
if df[col].isna().any():
|
|
89
|
+
if pd.api.types.is_numeric_dtype(df[col]):
|
|
90
|
+
median_val = df[col].median()
|
|
91
|
+
if pd.notna(median_val):
|
|
92
|
+
df[col] = df[col].fillna(median_val)
|
|
93
|
+
filled_cols.append(col)
|
|
94
|
+
else:
|
|
95
|
+
mode_vals = df[col].mode()
|
|
96
|
+
if len(mode_vals):
|
|
97
|
+
fill_val = mode_vals[0] if pd.notna(mode_vals[0]) else ""
|
|
98
|
+
df[col] = df[col].fillna(fill_val)
|
|
99
|
+
filled_cols.append(col)
|
|
100
|
+
if filled_cols:
|
|
101
|
+
self.actions.append({
|
|
102
|
+
"action": "missing_values",
|
|
103
|
+
"message": f"Smart-filled {len(filled_cols)} column(s): {filled_cols}.",
|
|
104
|
+
})
|
|
105
|
+
elif isinstance(missing, dict):
|
|
106
|
+
df.fillna(missing, inplace=True)
|
|
107
|
+
self.actions.append({
|
|
108
|
+
"action": "missing_values",
|
|
109
|
+
"message": f"Filled missing values using explicit mapping: {missing}.",
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# 6. Date parsing
|
|
113
|
+
if dates:
|
|
114
|
+
parsed_cols = []
|
|
115
|
+
for col in df.select_dtypes(include=["object"]).columns:
|
|
116
|
+
sample = df[col].dropna().head(20)
|
|
117
|
+
if len(sample) < 2:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
pd.to_datetime(sample, errors="raise")
|
|
121
|
+
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
122
|
+
parsed_cols.append(col)
|
|
123
|
+
except (ValueError, TypeError):
|
|
124
|
+
pass
|
|
125
|
+
if parsed_cols:
|
|
126
|
+
self.actions.append({
|
|
127
|
+
"action": "date_parsing",
|
|
128
|
+
"message": f"Parsed {len(parsed_cols)} date column(s): {parsed_cols}.",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
self._frame._df = df
|
|
132
|
+
return self._frame
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Cleaning report generator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from tablofy.core.frame import TablofyFrame
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CleanReport:
|
|
12
|
+
"""Summarises the most recent cleaning run."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, frame: TablofyFrame) -> None:
|
|
15
|
+
self._frame = frame
|
|
16
|
+
|
|
17
|
+
def summary(self) -> dict:
|
|
18
|
+
"""Return a dict with actions, action_count, and summary_text."""
|
|
19
|
+
actions = self._frame._last_clean_actions
|
|
20
|
+
count = len(actions)
|
|
21
|
+
if count:
|
|
22
|
+
summary_text = f"Cleaning completed: {count} action(s) performed."
|
|
23
|
+
else:
|
|
24
|
+
summary_text = "No cleaning operations recorded."
|
|
25
|
+
return {
|
|
26
|
+
"actions": actions,
|
|
27
|
+
"action_count": count,
|
|
28
|
+
"summary_text": summary_text,
|
|
29
|
+
}
|
tablofy/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core components: frame, loader, errors, config."""
|
tablofy/core/config.py
ADDED
tablofy/core/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exception classes for Tablofy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TablofyError(Exception):
|
|
5
|
+
"""Base exception for all Tablofy errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TablofyFileError(TablofyError):
|
|
9
|
+
"""Raised when a file operation fails (not found, unsupported format)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TablofyColumnError(TablofyError):
|
|
13
|
+
"""Raised when a requested column does not exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TablofyDataError(TablofyError):
|
|
17
|
+
"""Raised on invalid data (empty dataset, coercion failure)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TablofyChartError(TablofyError):
|
|
21
|
+
"""Raised when a chart operation is invalid."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TablofyParseError(TablofyError):
|
|
25
|
+
"""Raised when parsing a user string (e.g. chart description) fails."""
|