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.
@@ -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")
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version (read by hatchling)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)