murthylytics 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.
- murthylytics/__init__.py +274 -0
- murthylytics/_version.py +3 -0
- murthylytics/cleaning/__init__.py +1 -0
- murthylytics/core/__init__.py +48 -0
- murthylytics/core/config.py +88 -0
- murthylytics/core/context.py +125 -0
- murthylytics/core/exceptions.py +54 -0
- murthylytics/core/logging.py +75 -0
- murthylytics/core/optional.py +32 -0
- murthylytics/core/types.py +50 -0
- murthylytics/eda/__init__.py +1 -0
- murthylytics/exploration/__init__.py +36 -0
- murthylytics/exploration/display.py +99 -0
- murthylytics/exploration/inspect.py +337 -0
- murthylytics/features/__init__.py +1 -0
- murthylytics/io/__init__.py +28 -0
- murthylytics/io/detect.py +125 -0
- murthylytics/io/optimize.py +84 -0
- murthylytics/io/readers.py +160 -0
- murthylytics/modeling/__init__.py +1 -0
- murthylytics/pipeline/__init__.py +1 -0
- murthylytics/plugins/__init__.py +1 -0
- murthylytics/preprocess/__init__.py +1 -0
- murthylytics/py.typed +0 -0
- murthylytics/viz/__init__.py +1 -0
- murthylytics-0.1.0.dist-info/METADATA +168 -0
- murthylytics-0.1.0.dist-info/RECORD +29 -0
- murthylytics-0.1.0.dist-info/WHEEL +4 -0
- murthylytics-0.1.0.dist-info/licenses/LICENSE +21 -0
murthylytics/__init__.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Murthylytics — production-grade, AI-powered ML data analysis & preparation.
|
|
2
|
+
|
|
3
|
+
The package exposes a **stateful facade**: load a dataset once and every other
|
|
4
|
+
call operates on the active session::
|
|
5
|
+
|
|
6
|
+
import murthylytics as mlt
|
|
7
|
+
|
|
8
|
+
mlt.read_csv("data.csv")
|
|
9
|
+
mlt.summary()
|
|
10
|
+
mlt.inspect()
|
|
11
|
+
|
|
12
|
+
Advanced users can instead work with explicit :class:`~murthylytics.core.Session`
|
|
13
|
+
objects and the pure engine functions in the sub-packages.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from . import exploration as _exploration
|
|
23
|
+
from . import io as _io
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .core import (
|
|
26
|
+
Config,
|
|
27
|
+
Session,
|
|
28
|
+
enable_logging,
|
|
29
|
+
get_session,
|
|
30
|
+
reset_session,
|
|
31
|
+
)
|
|
32
|
+
from .core.context import get_session as _get_session
|
|
33
|
+
from .core.exceptions import MurthylyticsError, NoActiveDatasetError
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"__version__",
|
|
37
|
+
# data I/O
|
|
38
|
+
"read_csv",
|
|
39
|
+
"read_excel",
|
|
40
|
+
"read_json",
|
|
41
|
+
"read_parquet",
|
|
42
|
+
"read_sql",
|
|
43
|
+
# exploration
|
|
44
|
+
"show",
|
|
45
|
+
"top",
|
|
46
|
+
"bottom",
|
|
47
|
+
"shape",
|
|
48
|
+
"columns",
|
|
49
|
+
"summary",
|
|
50
|
+
"describe",
|
|
51
|
+
"info",
|
|
52
|
+
"types",
|
|
53
|
+
"missing",
|
|
54
|
+
"duplicates",
|
|
55
|
+
"memory",
|
|
56
|
+
"inspect",
|
|
57
|
+
# workflow (implemented incrementally)
|
|
58
|
+
"clean_data",
|
|
59
|
+
"cleaned_pyplot",
|
|
60
|
+
"auto_eda",
|
|
61
|
+
"preprocess",
|
|
62
|
+
"engineer_features",
|
|
63
|
+
"select_features",
|
|
64
|
+
"recommend_model",
|
|
65
|
+
"export_pipeline",
|
|
66
|
+
"use_plugin",
|
|
67
|
+
# session management
|
|
68
|
+
"set_target",
|
|
69
|
+
"reset",
|
|
70
|
+
"get_dataframe",
|
|
71
|
+
"get_session",
|
|
72
|
+
"reset_session",
|
|
73
|
+
"enable_logging",
|
|
74
|
+
"Config",
|
|
75
|
+
"Session",
|
|
76
|
+
"MurthylyticsError",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _df() -> pd.DataFrame:
|
|
81
|
+
"""Return the active session's dataframe or raise if none is loaded."""
|
|
82
|
+
return _get_session().df
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Data I/O — read, then install into the active session.
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
def read_csv(path: Any, **kwargs: Any) -> pd.DataFrame:
|
|
89
|
+
"""Read a CSV file and make it the active dataset. See :func:`io.read_csv`."""
|
|
90
|
+
session = _get_session()
|
|
91
|
+
df = _io.read_csv(path, config=session.config, **kwargs)
|
|
92
|
+
return session.load(df, source=str(path))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_excel(path: Any, **kwargs: Any) -> pd.DataFrame:
|
|
96
|
+
"""Read an Excel file and make it the active dataset."""
|
|
97
|
+
session = _get_session()
|
|
98
|
+
df = _io.read_excel(path, config=session.config, **kwargs)
|
|
99
|
+
return session.load(df, source=str(path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_json(path: Any, **kwargs: Any) -> pd.DataFrame:
|
|
103
|
+
"""Read a JSON file and make it the active dataset."""
|
|
104
|
+
session = _get_session()
|
|
105
|
+
df = _io.read_json(path, config=session.config, **kwargs)
|
|
106
|
+
return session.load(df, source=str(path))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_parquet(path: Any, **kwargs: Any) -> pd.DataFrame:
|
|
110
|
+
"""Read a Parquet file and make it the active dataset."""
|
|
111
|
+
session = _get_session()
|
|
112
|
+
df = _io.read_parquet(path, config=session.config, **kwargs)
|
|
113
|
+
return session.load(df, source=str(path))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_sql(query: str, con: Any, **kwargs: Any) -> pd.DataFrame:
|
|
117
|
+
"""Run a SQL query and make the result the active dataset."""
|
|
118
|
+
session = _get_session()
|
|
119
|
+
df = _io.read_sql(query, con, config=session.config, **kwargs)
|
|
120
|
+
return session.load(df, source="sql")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Exploration.
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
def show(n: Optional[int] = None) -> pd.DataFrame:
|
|
127
|
+
"""Return the active dataframe (or its first ``n`` rows)."""
|
|
128
|
+
df = _df()
|
|
129
|
+
return df if n is None else df.head(n)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def top(n: int = 5) -> pd.DataFrame:
|
|
133
|
+
"""Return the first ``n`` rows of the active dataset."""
|
|
134
|
+
return _exploration.top(_df(), n)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def bottom(n: int = 5) -> pd.DataFrame:
|
|
138
|
+
"""Return the last ``n`` rows of the active dataset."""
|
|
139
|
+
return _exploration.bottom(_df(), n)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def shape() -> Dict[str, int]:
|
|
143
|
+
"""Return row/column counts of the active dataset."""
|
|
144
|
+
return _exploration.shape(_df())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def columns() -> List[str]:
|
|
148
|
+
"""Return the active dataset's column names."""
|
|
149
|
+
return _exploration.columns(_df())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def summary() -> Dict[str, Any]:
|
|
153
|
+
"""Return a compact overview of the active dataset."""
|
|
154
|
+
return _exploration.summary(_df())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def describe(include: str = "all") -> pd.DataFrame:
|
|
158
|
+
"""Return descriptive statistics for the active dataset."""
|
|
159
|
+
return _exploration.describe(_df(), include=include)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def info() -> Dict[str, Any]:
|
|
163
|
+
"""Return structured dtype/non-null/memory info for the active dataset."""
|
|
164
|
+
return _exploration.info(_df())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def types() -> Dict[str, str]:
|
|
168
|
+
"""Return a column -> dtype mapping for the active dataset."""
|
|
169
|
+
return _exploration.types(_df())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def missing() -> pd.DataFrame:
|
|
173
|
+
"""Return per-column missing-value counts and percentages."""
|
|
174
|
+
return _exploration.missing(_df())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def duplicates() -> Dict[str, Any]:
|
|
178
|
+
"""Return duplicate-row statistics for the active dataset."""
|
|
179
|
+
return _exploration.duplicates(_df())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def memory() -> Dict[str, float]:
|
|
183
|
+
"""Return a per-dtype memory footprint report (MB)."""
|
|
184
|
+
return _exploration.memory(_df())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def inspect() -> "_exploration.InspectionReport":
|
|
188
|
+
"""Run a full intelligent inspection of the active dataset."""
|
|
189
|
+
session = _get_session()
|
|
190
|
+
report = _exploration.inspect(session.df, config=session.config)
|
|
191
|
+
if report.potential_targets and session.target is None:
|
|
192
|
+
session.metadata["suggested_target"] = report.potential_targets[0]
|
|
193
|
+
return report
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Session management.
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
def set_target(column: str) -> None:
|
|
200
|
+
"""Declare the target/label column for downstream steps."""
|
|
201
|
+
session = _get_session()
|
|
202
|
+
if column not in session.df.columns:
|
|
203
|
+
raise MurthylyticsError(f"Column '{column}' is not in the active dataset.")
|
|
204
|
+
session.target = column
|
|
205
|
+
session.record("set_target", column=column)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def reset() -> pd.DataFrame:
|
|
209
|
+
"""Restore the active dataframe to its originally loaded state."""
|
|
210
|
+
session = _get_session()
|
|
211
|
+
session.reset()
|
|
212
|
+
return session.df
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_dataframe() -> pd.DataFrame:
|
|
216
|
+
"""Return the active dataframe (a live reference, not a copy)."""
|
|
217
|
+
return _df()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Workflow API — implemented in later iterations. These raise a clear,
|
|
222
|
+
# actionable error today so the public surface is stable and discoverable.
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
def _not_yet(name: str, iteration: str) -> "NotImplementedError":
|
|
225
|
+
return NotImplementedError(
|
|
226
|
+
f"`mlt.{name}()` is part of the Murthylytics roadmap ({iteration}). "
|
|
227
|
+
f"The function is reserved and its signature is stable; the engine lands "
|
|
228
|
+
f"in an upcoming release."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def clean_data(**kwargs: Any) -> pd.DataFrame:
|
|
233
|
+
"""Automatically clean the active dataset (roadmap: cleaning module)."""
|
|
234
|
+
raise _not_yet("clean_data", "iteration 2 — cleaning")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cleaned_pyplot(**kwargs: Any) -> Any:
|
|
238
|
+
"""Auto-generate diagnostic plots (roadmap: viz module)."""
|
|
239
|
+
raise _not_yet("cleaned_pyplot", "iteration 3 — visualization")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def auto_eda(**kwargs: Any) -> Any:
|
|
243
|
+
"""Generate HTML/MD/JSON/PDF EDA reports (roadmap: eda module)."""
|
|
244
|
+
raise _not_yet("auto_eda", "iteration 4 — AutoEDA reporting")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def preprocess(**kwargs: Any) -> Any:
|
|
248
|
+
"""Detect the task and build a preprocessing pipeline (roadmap: preprocess)."""
|
|
249
|
+
raise _not_yet("preprocess", "iteration 5 — preprocessing")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def engineer_features(**kwargs: Any) -> Any:
|
|
253
|
+
"""Automatically engineer features (roadmap: features module)."""
|
|
254
|
+
raise _not_yet("engineer_features", "iteration 6 — feature engineering")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def select_features(**kwargs: Any) -> Any:
|
|
258
|
+
"""Select the most informative features (roadmap: features module)."""
|
|
259
|
+
raise _not_yet("select_features", "iteration 6 — feature selection")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def recommend_model(**kwargs: Any) -> Any:
|
|
263
|
+
"""Recommend suitable models with justification (roadmap: modeling)."""
|
|
264
|
+
raise _not_yet("recommend_model", "iteration 7 — model recommendation")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def export_pipeline(path: Any, **kwargs: Any) -> Any:
|
|
268
|
+
"""Export the reproducible preprocessing pipeline (roadmap: pipeline)."""
|
|
269
|
+
raise _not_yet("export_pipeline", "iteration 8 — pipeline export")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def use_plugin(name: str, **kwargs: Any) -> Any:
|
|
273
|
+
"""Activate a domain plugin, e.g. ``mlt.use_plugin('malware')`` (roadmap)."""
|
|
274
|
+
raise _not_yet("use_plugin", "iteration 9 — plugin architecture")
|
murthylytics/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Murthylytics cleaning module (roadmap — see project README for status)."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Core infrastructure: config, session/context, logging, types, exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import DEFAULT_CONFIG, Config
|
|
6
|
+
from .context import (
|
|
7
|
+
Operation,
|
|
8
|
+
Session,
|
|
9
|
+
get_session,
|
|
10
|
+
reset_session,
|
|
11
|
+
set_session,
|
|
12
|
+
)
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
ConfigurationError,
|
|
15
|
+
DataLoadError,
|
|
16
|
+
DetectionError,
|
|
17
|
+
MissingDependencyError,
|
|
18
|
+
MurthylyticsError,
|
|
19
|
+
NoActiveDatasetError,
|
|
20
|
+
NotFittedError,
|
|
21
|
+
PluginError,
|
|
22
|
+
)
|
|
23
|
+
from .logging import disable_logging, enable_logging, get_logger
|
|
24
|
+
from .types import ColumnRole, Severity, TaskType
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Config",
|
|
28
|
+
"DEFAULT_CONFIG",
|
|
29
|
+
"Session",
|
|
30
|
+
"Operation",
|
|
31
|
+
"get_session",
|
|
32
|
+
"set_session",
|
|
33
|
+
"reset_session",
|
|
34
|
+
"MurthylyticsError",
|
|
35
|
+
"NoActiveDatasetError",
|
|
36
|
+
"DataLoadError",
|
|
37
|
+
"DetectionError",
|
|
38
|
+
"MissingDependencyError",
|
|
39
|
+
"ConfigurationError",
|
|
40
|
+
"PluginError",
|
|
41
|
+
"NotFittedError",
|
|
42
|
+
"get_logger",
|
|
43
|
+
"enable_logging",
|
|
44
|
+
"disable_logging",
|
|
45
|
+
"ColumnRole",
|
|
46
|
+
"TaskType",
|
|
47
|
+
"Severity",
|
|
48
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""User-tunable configuration for Murthylytics.
|
|
2
|
+
|
|
3
|
+
A single immutable-by-convention :class:`Config` dataclass carries the knobs that
|
|
4
|
+
influence automatic detection, cleaning heuristics and inspection thresholds.
|
|
5
|
+
Defaults are chosen to be sensible for medium-sized tabular datasets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from .exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Config:
|
|
18
|
+
"""Global configuration knobs.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
sample_rows:
|
|
23
|
+
Number of rows sampled for expensive detection routines (encoding,
|
|
24
|
+
delimiter, dtype inference) on large files.
|
|
25
|
+
high_cardinality_ratio:
|
|
26
|
+
A categorical column whose unique-count / row-count exceeds this ratio
|
|
27
|
+
is flagged as high cardinality.
|
|
28
|
+
id_unique_ratio:
|
|
29
|
+
Columns whose unique-value ratio is at least this are considered
|
|
30
|
+
ID-like candidates for removal.
|
|
31
|
+
constant_threshold:
|
|
32
|
+
Columns where the most frequent value covers at least this fraction of
|
|
33
|
+
rows are treated as (quasi-)constant.
|
|
34
|
+
missing_drop_column_ratio:
|
|
35
|
+
Columns missing at least this fraction of values are proposed for removal.
|
|
36
|
+
outlier_zscore_threshold:
|
|
37
|
+
Absolute z-score above which a point is an outlier under the z-score rule.
|
|
38
|
+
outlier_iqr_multiplier:
|
|
39
|
+
IQR multiplier (Tukey's fences) for the IQR outlier rule.
|
|
40
|
+
correlation_threshold:
|
|
41
|
+
Absolute correlation above which a numeric pair is reported as strongly
|
|
42
|
+
correlated (and a data-leakage candidate against the target).
|
|
43
|
+
class_imbalance_ratio:
|
|
44
|
+
minority/majority class-frequency ratio below which a target is flagged
|
|
45
|
+
as imbalanced.
|
|
46
|
+
memory_optimize:
|
|
47
|
+
Whether readers downcast numeric/categorical dtypes to save memory.
|
|
48
|
+
random_state:
|
|
49
|
+
Seed used by any stochastic routine for reproducibility.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
sample_rows: int = 10_000
|
|
53
|
+
high_cardinality_ratio: float = 0.5
|
|
54
|
+
id_unique_ratio: float = 0.95
|
|
55
|
+
constant_threshold: float = 0.99
|
|
56
|
+
missing_drop_column_ratio: float = 0.6
|
|
57
|
+
outlier_zscore_threshold: float = 3.0
|
|
58
|
+
outlier_iqr_multiplier: float = 1.5
|
|
59
|
+
correlation_threshold: float = 0.9
|
|
60
|
+
class_imbalance_ratio: float = 0.2
|
|
61
|
+
memory_optimize: bool = True
|
|
62
|
+
random_state: int = 42
|
|
63
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if self.sample_rows <= 0:
|
|
67
|
+
raise ConfigurationError("sample_rows must be a positive integer.")
|
|
68
|
+
for name in (
|
|
69
|
+
"high_cardinality_ratio",
|
|
70
|
+
"id_unique_ratio",
|
|
71
|
+
"constant_threshold",
|
|
72
|
+
"missing_drop_column_ratio",
|
|
73
|
+
"class_imbalance_ratio",
|
|
74
|
+
):
|
|
75
|
+
value = getattr(self, name)
|
|
76
|
+
if not 0.0 <= value <= 1.0:
|
|
77
|
+
raise ConfigurationError(f"{name} must be within [0, 1]; got {value}.")
|
|
78
|
+
|
|
79
|
+
def with_overrides(self, **overrides: Any) -> "Config":
|
|
80
|
+
"""Return a new :class:`Config` with the given fields replaced."""
|
|
81
|
+
valid = {f for f in self.__dataclass_fields__ if f != "extra"}
|
|
82
|
+
known = {k: v for k, v in overrides.items() if k in valid}
|
|
83
|
+
unknown = {k: v for k, v in overrides.items() if k not in valid}
|
|
84
|
+
new_extra = {**self.extra, **unknown}
|
|
85
|
+
return replace(self, extra=new_extra, **known)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DEFAULT_CONFIG = Config()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The stateful session that backs the ``import murthylytics as mlt`` facade.
|
|
2
|
+
|
|
3
|
+
The facade functions (``mlt.read_csv``, ``mlt.summary`` ...) are thin wrappers
|
|
4
|
+
that read from / write to a single active :class:`Session`. Keeping *all* mutable
|
|
5
|
+
state in one object means the underlying engine modules stay pure and testable,
|
|
6
|
+
and it lets advanced users work with explicit :class:`Session` objects instead
|
|
7
|
+
of the global one when they need isolation (e.g. threads, notebooks, tests).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .config import DEFAULT_CONFIG, Config
|
|
16
|
+
from .exceptions import NoActiveDatasetError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Operation:
|
|
24
|
+
"""A single recorded step in the session's provenance log."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Session:
|
|
31
|
+
"""Holds the active dataframe and everything derived from it.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
config:
|
|
36
|
+
Configuration knobs; falls back to :data:`DEFAULT_CONFIG`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: Optional[Config] = None) -> None:
|
|
40
|
+
self.config: Config = config or DEFAULT_CONFIG
|
|
41
|
+
self._df: Optional["pd.DataFrame"] = None
|
|
42
|
+
self._raw_df: Optional["pd.DataFrame"] = None # untouched original
|
|
43
|
+
self.source: Optional[str] = None
|
|
44
|
+
self.target: Optional[str] = None
|
|
45
|
+
self.metadata: Dict[str, Any] = {}
|
|
46
|
+
self.history: List[Operation] = []
|
|
47
|
+
|
|
48
|
+
# -- dataframe access -------------------------------------------------
|
|
49
|
+
@property
|
|
50
|
+
def df(self) -> "pd.DataFrame":
|
|
51
|
+
"""The active (possibly transformed) dataframe.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
NoActiveDatasetError
|
|
56
|
+
If no dataset has been loaded.
|
|
57
|
+
"""
|
|
58
|
+
if self._df is None:
|
|
59
|
+
raise NoActiveDatasetError()
|
|
60
|
+
return self._df
|
|
61
|
+
|
|
62
|
+
@df.setter
|
|
63
|
+
def df(self, value: "pd.DataFrame") -> None:
|
|
64
|
+
self._df = value
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def has_data(self) -> bool:
|
|
68
|
+
return self._df is not None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def raw_df(self) -> "pd.DataFrame":
|
|
72
|
+
"""The original dataframe as first loaded, before any transforms."""
|
|
73
|
+
if self._raw_df is None:
|
|
74
|
+
raise NoActiveDatasetError()
|
|
75
|
+
return self._raw_df
|
|
76
|
+
|
|
77
|
+
def load(
|
|
78
|
+
self,
|
|
79
|
+
df: "pd.DataFrame",
|
|
80
|
+
*,
|
|
81
|
+
source: Optional[str] = None,
|
|
82
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
83
|
+
) -> "pd.DataFrame":
|
|
84
|
+
"""Install ``df`` as the active dataset and reset derived state."""
|
|
85
|
+
self._df = df
|
|
86
|
+
self._raw_df = df.copy(deep=True)
|
|
87
|
+
self.source = source
|
|
88
|
+
self.target = None
|
|
89
|
+
self.metadata = metadata or {}
|
|
90
|
+
self.history = [Operation("load", {"source": source, "shape": tuple(df.shape)})]
|
|
91
|
+
return df
|
|
92
|
+
|
|
93
|
+
def record(self, name: str, **details: Any) -> None:
|
|
94
|
+
"""Append an entry to the provenance log."""
|
|
95
|
+
self.history.append(Operation(name, details))
|
|
96
|
+
|
|
97
|
+
def reset(self) -> None:
|
|
98
|
+
"""Restore the active dataframe to its originally loaded state."""
|
|
99
|
+
if self._raw_df is None:
|
|
100
|
+
raise NoActiveDatasetError()
|
|
101
|
+
self._df = self._raw_df.copy(deep=True)
|
|
102
|
+
self.record("reset")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
# Global singleton used by the facade.
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
_active_session: Session = Session()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_session() -> Session:
|
|
112
|
+
"""Return the process-wide active :class:`Session`."""
|
|
113
|
+
return _active_session
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def set_session(session: Session) -> Session:
|
|
117
|
+
"""Replace the active session (mainly for advanced users and tests)."""
|
|
118
|
+
global _active_session
|
|
119
|
+
_active_session = session
|
|
120
|
+
return _active_session
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def reset_session(config: Optional[Config] = None) -> Session:
|
|
124
|
+
"""Discard all state and start a fresh active session."""
|
|
125
|
+
return set_session(Session(config=config))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception hierarchy for Murthylytics.
|
|
2
|
+
|
|
3
|
+
All library-specific errors derive from :class:`MurthylyticsError`, so callers
|
|
4
|
+
can catch everything the library raises with a single ``except``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MurthylyticsError(Exception):
|
|
11
|
+
"""Base class for every error raised by Murthylytics."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoActiveDatasetError(MurthylyticsError):
|
|
15
|
+
"""Raised when a facade call needs data but none has been loaded yet."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str | None = None) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
message
|
|
20
|
+
or "No active dataset. Load one first, e.g. `mlt.read_csv('data.csv')`."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DataLoadError(MurthylyticsError):
|
|
25
|
+
"""Raised when a dataset cannot be read or parsed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DetectionError(MurthylyticsError):
|
|
29
|
+
"""Raised when automatic detection (encoding, delimiter, dtype) fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MissingDependencyError(MurthylyticsError):
|
|
33
|
+
"""Raised when an optional dependency is required but not installed."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, package: str, extra: str | None = None) -> None:
|
|
36
|
+
hint = f"pip install 'murthylytics[{extra}]'" if extra else f"pip install {package}"
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"The optional dependency '{package}' is required for this feature. "
|
|
39
|
+
f"Install it with: {hint}"
|
|
40
|
+
)
|
|
41
|
+
self.package = package
|
|
42
|
+
self.extra = extra
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigurationError(MurthylyticsError):
|
|
46
|
+
"""Raised when configuration values are invalid or inconsistent."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PluginError(MurthylyticsError):
|
|
50
|
+
"""Raised for plugin registration or execution failures."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFittedError(MurthylyticsError):
|
|
54
|
+
"""Raised when a pipeline/transform is used before being fitted."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Centralised logging configuration for Murthylytics.
|
|
2
|
+
|
|
3
|
+
Uses a single library-root logger (``murthylytics``). Following best practice
|
|
4
|
+
for libraries, we attach a :class:`~logging.NullHandler` by default so importing
|
|
5
|
+
the package never emits output; :func:`enable_logging` opts the user in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
_ROOT_NAME = "murthylytics"
|
|
14
|
+
|
|
15
|
+
# Attach a NullHandler once so "No handler found" warnings never appear.
|
|
16
|
+
logging.getLogger(_ROOT_NAME).addHandler(logging.NullHandler())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
20
|
+
"""Return a child logger under the ``murthylytics`` namespace.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
name:
|
|
25
|
+
Dotted suffix, typically ``__name__`` of the calling module. When it
|
|
26
|
+
already lives under the package, it is returned unchanged.
|
|
27
|
+
"""
|
|
28
|
+
if not name or name == _ROOT_NAME:
|
|
29
|
+
return logging.getLogger(_ROOT_NAME)
|
|
30
|
+
if name.startswith(_ROOT_NAME + "."):
|
|
31
|
+
return logging.getLogger(name)
|
|
32
|
+
# Turn "murthylytics.io.readers" style module paths into a clean suffix.
|
|
33
|
+
suffix = name.split(".")[-1]
|
|
34
|
+
return logging.getLogger(f"{_ROOT_NAME}.{suffix}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def enable_logging(level: int | str = logging.INFO) -> None:
|
|
38
|
+
"""Turn on console logging for the library.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
level:
|
|
43
|
+
A :mod:`logging` level (``logging.DEBUG``) or its name (``"DEBUG"``).
|
|
44
|
+
"""
|
|
45
|
+
logger = logging.getLogger(_ROOT_NAME)
|
|
46
|
+
if isinstance(level, str):
|
|
47
|
+
level = logging.getLevelName(level.upper())
|
|
48
|
+
logger.setLevel(level)
|
|
49
|
+
|
|
50
|
+
# Avoid stacking duplicate stream handlers on repeated calls.
|
|
51
|
+
for handler in logger.handlers:
|
|
52
|
+
if isinstance(handler, logging.StreamHandler) and not isinstance(
|
|
53
|
+
handler, logging.NullHandler
|
|
54
|
+
):
|
|
55
|
+
handler.setLevel(level)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
handler = logging.StreamHandler()
|
|
59
|
+
handler.setLevel(level)
|
|
60
|
+
handler.setFormatter(
|
|
61
|
+
logging.Formatter(
|
|
62
|
+
"%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
|
63
|
+
datefmt="%H:%M:%S",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
logger.addHandler(handler)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def disable_logging() -> None:
|
|
70
|
+
"""Silence the library logger again."""
|
|
71
|
+
logger = logging.getLogger(_ROOT_NAME)
|
|
72
|
+
for handler in list(logger.handlers):
|
|
73
|
+
if not isinstance(handler, logging.NullHandler):
|
|
74
|
+
logger.removeHandler(handler)
|
|
75
|
+
logger.setLevel(logging.WARNING)
|