featuresmith-core 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.
- featuresmith/__init__.py +7 -0
- featuresmith/api.py +180 -0
- featuresmith/connectors/__init__.py +18 -0
- featuresmith/connectors/_paths.py +25 -0
- featuresmith/connectors/base.py +54 -0
- featuresmith/connectors/csv_connector.py +44 -0
- featuresmith/connectors/dataframe_connector.py +40 -0
- featuresmith/connectors/excel_connector.py +45 -0
- featuresmith/connectors/parquet_connector.py +44 -0
- featuresmith/connectors/registry.py +55 -0
- featuresmith/core/__init__.py +50 -0
- featuresmith/core/dataset.py +107 -0
- featuresmith/core/exceptions.py +17 -0
- featuresmith/core/profile_result.py +412 -0
- featuresmith/core/rule_finding.py +44 -0
- featuresmith/core/rule_result.py +49 -0
- featuresmith/core/schema.py +34 -0
- featuresmith/profiling/__init__.py +5 -0
- featuresmith/profiling/categorical.py +108 -0
- featuresmith/profiling/correlation.py +67 -0
- featuresmith/profiling/datetime.py +105 -0
- featuresmith/profiling/duplicates.py +48 -0
- featuresmith/profiling/missing.py +70 -0
- featuresmith/profiling/numeric.py +248 -0
- featuresmith/profiling/profiler.py +209 -0
- featuresmith/profiling/quality.py +29 -0
- featuresmith/profiling/summary.py +117 -0
- featuresmith/profiling/text.py +109 -0
- featuresmith/py.typed +1 -0
- featuresmith/rules/__init__.py +27 -0
- featuresmith/rules/base.py +69 -0
- featuresmith/rules/cardinality.py +95 -0
- featuresmith/rules/constants.py +115 -0
- featuresmith/rules/correlation.py +77 -0
- featuresmith/rules/duplicates.py +76 -0
- featuresmith/rules/engine.py +156 -0
- featuresmith/rules/leakage.py +93 -0
- featuresmith/rules/missing.py +83 -0
- featuresmith/rules/outliers.py +113 -0
- featuresmith/rules/registry.py +81 -0
- featuresmith_core-0.1.0.dist-info/METADATA +50 -0
- featuresmith_core-0.1.0.dist-info/RECORD +45 -0
- featuresmith_core-0.1.0.dist-info/WHEEL +5 -0
- featuresmith_core-0.1.0.dist-info/licenses/LICENSE +187 -0
- featuresmith_core-0.1.0.dist-info/top_level.txt +1 -0
featuresmith/__init__.py
ADDED
featuresmith/api.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Public SDK entry module for Featuresmith."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from featuresmith.connectors.registry import default_registry
|
|
8
|
+
from featuresmith.core.dataset import Dataset as Dataset
|
|
9
|
+
from featuresmith.core.exceptions import (
|
|
10
|
+
ConnectorError as ConnectorError,
|
|
11
|
+
)
|
|
12
|
+
from featuresmith.core.exceptions import (
|
|
13
|
+
SourceNotFoundError as SourceNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from featuresmith.core.exceptions import (
|
|
16
|
+
SourceParseError as SourceParseError,
|
|
17
|
+
)
|
|
18
|
+
from featuresmith.core.exceptions import (
|
|
19
|
+
UnsupportedFormatError as UnsupportedFormatError,
|
|
20
|
+
)
|
|
21
|
+
from featuresmith.core.profile_result import ProfileResult as ProfileResult
|
|
22
|
+
from featuresmith.core.rule_result import RuleResult as RuleResult
|
|
23
|
+
from featuresmith.profiling import profile_dataset
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(source: object) -> Dataset:
|
|
27
|
+
"""Load a supported tabular source into a normalized Dataset.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
source: The data source to load. This can be a string representing a
|
|
31
|
+
local file path (CSV, Excel, Parquet), or an in-memory pandas
|
|
32
|
+
DataFrame or Polars DataFrame.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dataset: A normalized view of the loaded tabular dataset, containing
|
|
36
|
+
the dataframe backend, schema, dtypes, and metadata.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ConnectorError: If the source is missing, has an unsupported format,
|
|
40
|
+
is corrupted, or is of an invalid type.
|
|
41
|
+
|
|
42
|
+
Notes:
|
|
43
|
+
For file paths, Featuresmith utilizes Polars for CSV and Parquet formats
|
|
44
|
+
by default, and pandas for Excel formats. In-memory dataframes are
|
|
45
|
+
wrapped without copying the underlying memory.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> import pandas as pd
|
|
49
|
+
>>> import featuresmith as fs
|
|
50
|
+
>>> df = pd.DataFrame({"a": [1, 2, 3]})
|
|
51
|
+
>>> dataset = fs.load(df)
|
|
52
|
+
>>> dataset.row_count
|
|
53
|
+
3
|
|
54
|
+
"""
|
|
55
|
+
return default_registry().load(source)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def profile(
|
|
59
|
+
source: object,
|
|
60
|
+
*,
|
|
61
|
+
max_correlation_columns: int = 100,
|
|
62
|
+
max_frequency_table_size: int = 1000,
|
|
63
|
+
) -> ProfileResult:
|
|
64
|
+
"""Profile a supported source or Dataset and compute statistical summaries.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
source: The data source to profile. This can be a pre-loaded Dataset
|
|
68
|
+
object, a string representing a local file path (CSV, Excel,
|
|
69
|
+
Parquet), or an in-memory pandas or Polars DataFrame.
|
|
70
|
+
max_correlation_columns: Limit correlation computations to prevent
|
|
71
|
+
combinatorial blowup (default 100).
|
|
72
|
+
max_frequency_table_size: Maximum entries to keep in categorical
|
|
73
|
+
frequency tables (default 1000).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
ProfileResult: A strongly-typed statistical profile containing dataset
|
|
77
|
+
summaries, column profiles, correlations, and logical types.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ConnectorError: If the source is a file path or dataframe that fails to
|
|
81
|
+
load before profiling.
|
|
82
|
+
|
|
83
|
+
Notes:
|
|
84
|
+
This is a deterministic profiling engine. The statistical calculations
|
|
85
|
+
run on the underlying dataframe backend using vectorized Polars or pandas
|
|
86
|
+
APIs. The resulting ProfileResult is frozen and fully serializable.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
>>> import polars as pl
|
|
90
|
+
>>> import featuresmith as fs
|
|
91
|
+
>>> df = pl.DataFrame({"a": [1.0, 2.0, None]})
|
|
92
|
+
>>> prof = fs.profile(df)
|
|
93
|
+
>>> prof.dataset_summary.column_count
|
|
94
|
+
1
|
|
95
|
+
>>> prof.column_profiles["a"].missing_count
|
|
96
|
+
1
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(source, Dataset):
|
|
99
|
+
dataset = source
|
|
100
|
+
else:
|
|
101
|
+
dataset = load(source)
|
|
102
|
+
return profile_dataset(
|
|
103
|
+
dataset,
|
|
104
|
+
max_correlation_columns=max_correlation_columns,
|
|
105
|
+
max_frequency_table_size=max_frequency_table_size,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def analyze(
|
|
110
|
+
source: object,
|
|
111
|
+
*,
|
|
112
|
+
target_column: str | None = None,
|
|
113
|
+
enabled_rules: list[str] | None = None,
|
|
114
|
+
rule_config: dict[str, Any] | None = None,
|
|
115
|
+
max_correlation_columns: int = 100,
|
|
116
|
+
max_frequency_table_size: int = 1000,
|
|
117
|
+
) -> RuleResult:
|
|
118
|
+
"""Analyze a tabular source or Dataset, computing profile stats and running rules.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
source: The data source to analyze. This can be a pre-loaded Dataset
|
|
122
|
+
object, a string representing a local file path (CSV, Excel,
|
|
123
|
+
Parquet), or an in-memory pandas or Polars DataFrame.
|
|
124
|
+
target_column: Optional name of the target column in the dataset, used
|
|
125
|
+
specifically for evaluating potential target leakage.
|
|
126
|
+
enabled_rules: Optional list of rule IDs to execute. If not provided,
|
|
127
|
+
the engine runs all rules that are enabled by default.
|
|
128
|
+
rule_config: Optional dictionary of configurations keyed by rule ID.
|
|
129
|
+
For example, `{"quality.missing_value_threshold": {"threshold": 15.0}}`.
|
|
130
|
+
max_correlation_columns: Limit correlation computations during profiling to
|
|
131
|
+
prevent combinatorial blowup (default 100).
|
|
132
|
+
max_frequency_table_size: Maximum entries to keep in categorical
|
|
133
|
+
frequency tables (default 1000).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
RuleResult: The canonical output of the Rule Engine containing the
|
|
137
|
+
computed ProfileResult, aggregated list of RuleFindings, list of
|
|
138
|
+
executed rule IDs, and execution time metadata.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ConnectorError: If the source is a file path or dataframe that fails to
|
|
142
|
+
load before profiling.
|
|
143
|
+
|
|
144
|
+
Notes:
|
|
145
|
+
This function integrates connector loading, profiling, and rule
|
|
146
|
+
evaluation into a single public endpoint. The evaluation of rules is
|
|
147
|
+
deterministic and isolated; a crash in any single rule does not cause
|
|
148
|
+
the function to fail, but is recorded in the failed_rules mapping.
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
>>> import pandas as pd
|
|
152
|
+
>>> import featuresmith as fs
|
|
153
|
+
>>> df = pd.DataFrame({
|
|
154
|
+
... "x": [1, 2, 3, 4, 5],
|
|
155
|
+
... "y": [1.0, 2.0, 3.0, 4.0, 5.0],
|
|
156
|
+
... "target": [0, 1, 0, 1, 0]
|
|
157
|
+
... })
|
|
158
|
+
>>> result = fs.analyze(df, target_column="target")
|
|
159
|
+
>>> len(result.findings) >= 0
|
|
160
|
+
True
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(source, Dataset):
|
|
163
|
+
dataset = source
|
|
164
|
+
else:
|
|
165
|
+
dataset = load(source)
|
|
166
|
+
prof_res = profile(
|
|
167
|
+
dataset,
|
|
168
|
+
max_correlation_columns=max_correlation_columns,
|
|
169
|
+
max_frequency_table_size=max_frequency_table_size,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
from featuresmith.rules.engine import RuleEngine
|
|
173
|
+
|
|
174
|
+
engine = RuleEngine()
|
|
175
|
+
return engine.run(
|
|
176
|
+
prof_res,
|
|
177
|
+
target_column=target_column,
|
|
178
|
+
enabled_rules=enabled_rules,
|
|
179
|
+
rule_config=rule_config,
|
|
180
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Connector package for Featuresmith."""
|
|
2
|
+
|
|
3
|
+
from featuresmith.connectors.base import BaseConnector
|
|
4
|
+
from featuresmith.connectors.csv_connector import CsvConnector
|
|
5
|
+
from featuresmith.connectors.dataframe_connector import DataFrameConnector
|
|
6
|
+
from featuresmith.connectors.excel_connector import ExcelConnector
|
|
7
|
+
from featuresmith.connectors.parquet_connector import ParquetConnector
|
|
8
|
+
from featuresmith.connectors.registry import ConnectorRegistry, default_registry
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseConnector",
|
|
12
|
+
"ConnectorRegistry",
|
|
13
|
+
"CsvConnector",
|
|
14
|
+
"DataFrameConnector",
|
|
15
|
+
"ExcelConnector",
|
|
16
|
+
"ParquetConnector",
|
|
17
|
+
"default_registry",
|
|
18
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Private helpers shared by local-file connectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from featuresmith.core.exceptions import SourceNotFoundError, UnsupportedFormatError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_file_source(source: object, suffixes: tuple[str, ...]) -> Path:
|
|
11
|
+
"""Return a validated local file path for one of the supplied suffixes."""
|
|
12
|
+
if not isinstance(source, (str, Path)):
|
|
13
|
+
raise UnsupportedFormatError("Expected a local file path.")
|
|
14
|
+
|
|
15
|
+
path = Path(source)
|
|
16
|
+
if path.suffix.lower() not in suffixes:
|
|
17
|
+
formats = ", ".join(suffixes)
|
|
18
|
+
raise UnsupportedFormatError(
|
|
19
|
+
f"Unsupported file format. Expected one of: {formats}."
|
|
20
|
+
)
|
|
21
|
+
if not path.exists():
|
|
22
|
+
raise SourceNotFoundError(f"Source file does not exist: {path}.")
|
|
23
|
+
if not path.is_file():
|
|
24
|
+
raise SourceNotFoundError(f"Source path is not a file: {path}.")
|
|
25
|
+
return path
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Base contract for source connectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from featuresmith.core.dataset import Dataset
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseConnector(ABC):
|
|
11
|
+
"""Abstract base class for all Featuresmith data connectors.
|
|
12
|
+
|
|
13
|
+
Connectors are responsible for loading and normalizing external tabular
|
|
14
|
+
sources (such as files or database connections) or in-memory representations
|
|
15
|
+
into a standardized Dataset object.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def can_load(self, source: object) -> bool:
|
|
20
|
+
"""Determine whether the connector supports the supplied source.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
source: The input source object (e.g. file path or DataFrame).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
bool: True if this connector supports loading from the source,
|
|
27
|
+
False otherwise.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def validate(self, source: object) -> None:
|
|
32
|
+
"""Validate the source structurally or raise a typed connector error.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
source: The input source object to validate.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ConnectorError: If validation fails because the source is missing,
|
|
39
|
+
unsupported, or invalid.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def load(self, source: object) -> Dataset:
|
|
44
|
+
"""Load and normalize a validated source into a Dataset.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
source: The input source object to load.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dataset: The loaded and normalized dataset wrapper.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ConnectorError: If loading fails due to parsing or read errors.
|
|
54
|
+
"""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CSV connector backed by Polars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from featuresmith.connectors._paths import validate_file_source
|
|
10
|
+
from featuresmith.connectors.base import BaseConnector
|
|
11
|
+
from featuresmith.core.dataset import Dataset
|
|
12
|
+
from featuresmith.core.exceptions import SourceParseError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CsvConnector(BaseConnector):
|
|
16
|
+
"""Load local CSV files into Polars-backed datasets."""
|
|
17
|
+
|
|
18
|
+
_SUFFIXES = (".csv",)
|
|
19
|
+
|
|
20
|
+
def can_load(self, source: object) -> bool:
|
|
21
|
+
"""Return whether the source is a CSV path."""
|
|
22
|
+
return (
|
|
23
|
+
isinstance(source, (str, Path))
|
|
24
|
+
and Path(source).suffix.lower() in self._SUFFIXES
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def validate(self, source: object) -> None:
|
|
28
|
+
"""Validate that the CSV source exists and is a regular file."""
|
|
29
|
+
validate_file_source(source, self._SUFFIXES)
|
|
30
|
+
|
|
31
|
+
def load(self, source: object) -> Dataset:
|
|
32
|
+
"""Load a CSV file as a normalized Polars dataset."""
|
|
33
|
+
path = validate_file_source(source, self._SUFFIXES)
|
|
34
|
+
try:
|
|
35
|
+
dataframe = pl.read_csv(path)
|
|
36
|
+
except (OSError, pl.exceptions.PolarsError) as error:
|
|
37
|
+
raise SourceParseError(f"Could not read CSV file '{path}'.") from error
|
|
38
|
+
return Dataset.from_dataframe(
|
|
39
|
+
dataframe,
|
|
40
|
+
backend="polars",
|
|
41
|
+
source=str(path),
|
|
42
|
+
file_size=path.stat().st_size,
|
|
43
|
+
metadata={"format": "csv"},
|
|
44
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""In-memory pandas and Polars dataframe connector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from featuresmith.connectors.base import BaseConnector
|
|
9
|
+
from featuresmith.core.dataset import Dataset
|
|
10
|
+
from featuresmith.core.exceptions import UnsupportedFormatError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataFrameConnector(BaseConnector):
|
|
14
|
+
"""Wrap in-memory pandas or Polars dataframes without copying them."""
|
|
15
|
+
|
|
16
|
+
def can_load(self, source: object) -> bool:
|
|
17
|
+
"""Return whether the source is a supported dataframe instance."""
|
|
18
|
+
return isinstance(source, (pd.DataFrame, pl.DataFrame))
|
|
19
|
+
|
|
20
|
+
def validate(self, source: object) -> None:
|
|
21
|
+
"""Validate that the source is a pandas or Polars dataframe."""
|
|
22
|
+
if not self.can_load(source):
|
|
23
|
+
raise UnsupportedFormatError("Expected a pandas or Polars DataFrame.")
|
|
24
|
+
|
|
25
|
+
def load(self, source: object) -> Dataset:
|
|
26
|
+
"""Wrap an in-memory dataframe in the normalized dataset contract."""
|
|
27
|
+
self.validate(source)
|
|
28
|
+
if isinstance(source, pd.DataFrame):
|
|
29
|
+
return Dataset.from_dataframe(
|
|
30
|
+
source,
|
|
31
|
+
backend="pandas",
|
|
32
|
+
metadata={"format": "dataframe"},
|
|
33
|
+
)
|
|
34
|
+
if isinstance(source, pl.DataFrame):
|
|
35
|
+
return Dataset.from_dataframe(
|
|
36
|
+
source,
|
|
37
|
+
backend="polars",
|
|
38
|
+
metadata={"format": "dataframe"},
|
|
39
|
+
)
|
|
40
|
+
raise UnsupportedFormatError("Expected a pandas or Polars DataFrame.")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Excel connector backed by pandas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from featuresmith.connectors._paths import validate_file_source
|
|
11
|
+
from featuresmith.connectors.base import BaseConnector
|
|
12
|
+
from featuresmith.core.dataset import Dataset
|
|
13
|
+
from featuresmith.core.exceptions import SourceParseError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExcelConnector(BaseConnector):
|
|
17
|
+
"""Load the first worksheet from a local Excel workbook."""
|
|
18
|
+
|
|
19
|
+
_SUFFIXES = (".xlsx", ".xls", ".xlsm")
|
|
20
|
+
|
|
21
|
+
def can_load(self, source: object) -> bool:
|
|
22
|
+
"""Return whether the source is an Excel workbook path."""
|
|
23
|
+
return (
|
|
24
|
+
isinstance(source, (str, Path))
|
|
25
|
+
and Path(source).suffix.lower() in self._SUFFIXES
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def validate(self, source: object) -> None:
|
|
29
|
+
"""Validate that the Excel source exists and is a regular file."""
|
|
30
|
+
validate_file_source(source, self._SUFFIXES)
|
|
31
|
+
|
|
32
|
+
def load(self, source: object) -> Dataset:
|
|
33
|
+
"""Load the first worksheet as a normalized pandas dataset."""
|
|
34
|
+
path = validate_file_source(source, self._SUFFIXES)
|
|
35
|
+
try:
|
|
36
|
+
dataframe = pd.read_excel(path)
|
|
37
|
+
except (ImportError, OSError, ValueError, zipfile.BadZipFile) as error:
|
|
38
|
+
raise SourceParseError(f"Could not read Excel file '{path}'.") from error
|
|
39
|
+
return Dataset.from_dataframe(
|
|
40
|
+
dataframe,
|
|
41
|
+
backend="pandas",
|
|
42
|
+
source=str(path),
|
|
43
|
+
file_size=path.stat().st_size,
|
|
44
|
+
metadata={"format": "excel", "sheet": 0},
|
|
45
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Parquet connector backed by Polars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from featuresmith.connectors._paths import validate_file_source
|
|
10
|
+
from featuresmith.connectors.base import BaseConnector
|
|
11
|
+
from featuresmith.core.dataset import Dataset
|
|
12
|
+
from featuresmith.core.exceptions import SourceParseError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ParquetConnector(BaseConnector):
|
|
16
|
+
"""Load local Parquet files into Polars-backed datasets."""
|
|
17
|
+
|
|
18
|
+
_SUFFIXES = (".parquet", ".pq")
|
|
19
|
+
|
|
20
|
+
def can_load(self, source: object) -> bool:
|
|
21
|
+
"""Return whether the source is a Parquet path."""
|
|
22
|
+
return (
|
|
23
|
+
isinstance(source, (str, Path))
|
|
24
|
+
and Path(source).suffix.lower() in self._SUFFIXES
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def validate(self, source: object) -> None:
|
|
28
|
+
"""Validate that the Parquet source exists and is a regular file."""
|
|
29
|
+
validate_file_source(source, self._SUFFIXES)
|
|
30
|
+
|
|
31
|
+
def load(self, source: object) -> Dataset:
|
|
32
|
+
"""Load a Parquet file as a normalized Polars dataset."""
|
|
33
|
+
path = validate_file_source(source, self._SUFFIXES)
|
|
34
|
+
try:
|
|
35
|
+
dataframe = pl.read_parquet(path)
|
|
36
|
+
except (OSError, pl.exceptions.PolarsError) as error:
|
|
37
|
+
raise SourceParseError(f"Could not read Parquet file '{path}'.") from error
|
|
38
|
+
return Dataset.from_dataframe(
|
|
39
|
+
dataframe,
|
|
40
|
+
backend="polars",
|
|
41
|
+
source=str(path),
|
|
42
|
+
file_size=path.stat().st_size,
|
|
43
|
+
metadata={"format": "parquet"},
|
|
44
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Static connector registration and source dispatch for Sprint 2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from featuresmith.connectors.base import BaseConnector
|
|
8
|
+
from featuresmith.connectors.csv_connector import CsvConnector
|
|
9
|
+
from featuresmith.connectors.dataframe_connector import DataFrameConnector
|
|
10
|
+
from featuresmith.connectors.excel_connector import ExcelConnector
|
|
11
|
+
from featuresmith.connectors.parquet_connector import ParquetConnector
|
|
12
|
+
from featuresmith.core.dataset import Dataset
|
|
13
|
+
from featuresmith.core.exceptions import UnsupportedFormatError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectorRegistry:
|
|
17
|
+
"""Select a registered connector for a supported input source.
|
|
18
|
+
|
|
19
|
+
Registration is intentionally explicit during Sprint 2. It establishes the
|
|
20
|
+
extension boundary without entry-point discovery or dynamic loading.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, connectors: Iterable[BaseConnector] = ()) -> None:
|
|
24
|
+
"""Create a registry with optional initial connector instances."""
|
|
25
|
+
self._connectors = list(connectors)
|
|
26
|
+
|
|
27
|
+
def register(self, connector: BaseConnector) -> None:
|
|
28
|
+
"""Register a connector instance for subsequent source dispatch."""
|
|
29
|
+
self._connectors.append(connector)
|
|
30
|
+
|
|
31
|
+
def load(self, source: object) -> Dataset:
|
|
32
|
+
"""Load a source with the first registered connector that supports it.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConnectorError: If no registered connector supports the source.
|
|
36
|
+
"""
|
|
37
|
+
for connector in self._connectors:
|
|
38
|
+
if connector.can_load(source):
|
|
39
|
+
return connector.load(source)
|
|
40
|
+
raise UnsupportedFormatError(
|
|
41
|
+
"Unsupported source. Use a CSV, Excel, Parquet, pandas DataFrame, "
|
|
42
|
+
"or Polars DataFrame."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_registry() -> ConnectorRegistry:
|
|
47
|
+
"""Return the built-in Sprint 2 connector registry."""
|
|
48
|
+
return ConnectorRegistry(
|
|
49
|
+
(
|
|
50
|
+
DataFrameConnector(),
|
|
51
|
+
CsvConnector(),
|
|
52
|
+
ExcelConnector(),
|
|
53
|
+
ParquetConnector(),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Core primitives for Featuresmith."""
|
|
2
|
+
|
|
3
|
+
from featuresmith.core.dataset import Dataset
|
|
4
|
+
from featuresmith.core.exceptions import (
|
|
5
|
+
ConnectorError,
|
|
6
|
+
SourceNotFoundError,
|
|
7
|
+
SourceParseError,
|
|
8
|
+
UnsupportedFormatError,
|
|
9
|
+
)
|
|
10
|
+
from featuresmith.core.profile_result import (
|
|
11
|
+
CategoricalProfile,
|
|
12
|
+
ColumnProfile,
|
|
13
|
+
CorrelationSummary,
|
|
14
|
+
DatasetMetadata,
|
|
15
|
+
DatasetSummary,
|
|
16
|
+
DatetimeProfile,
|
|
17
|
+
DuplicateSummary,
|
|
18
|
+
ExecutionMetadata,
|
|
19
|
+
MissingValueSummary,
|
|
20
|
+
NumericProfile,
|
|
21
|
+
ProfileResult,
|
|
22
|
+
TextProfile,
|
|
23
|
+
)
|
|
24
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
25
|
+
from featuresmith.core.rule_result import RuleResult
|
|
26
|
+
from featuresmith.core.schema import ColumnSchema, DatasetSchema
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ColumnSchema",
|
|
30
|
+
"ConnectorError",
|
|
31
|
+
"SourceNotFoundError",
|
|
32
|
+
"SourceParseError",
|
|
33
|
+
"UnsupportedFormatError",
|
|
34
|
+
"Dataset",
|
|
35
|
+
"DatasetSchema",
|
|
36
|
+
"ColumnProfile",
|
|
37
|
+
"CategoricalProfile",
|
|
38
|
+
"CorrelationSummary",
|
|
39
|
+
"DatasetMetadata",
|
|
40
|
+
"DatasetSummary",
|
|
41
|
+
"DatetimeProfile",
|
|
42
|
+
"DuplicateSummary",
|
|
43
|
+
"ExecutionMetadata",
|
|
44
|
+
"MissingValueSummary",
|
|
45
|
+
"NumericProfile",
|
|
46
|
+
"ProfileResult",
|
|
47
|
+
"TextProfile",
|
|
48
|
+
"RuleFinding",
|
|
49
|
+
"RuleResult",
|
|
50
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Normalized dataset abstraction passed between Featuresmith pipeline stages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from featuresmith.core.schema import ColumnSchema, DatasetSchema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Dataset:
|
|
15
|
+
"""A lightweight, normalized view of a loaded tabular dataset.
|
|
16
|
+
|
|
17
|
+
The wrapper is shallowly immutable: its descriptive fields cannot be
|
|
18
|
+
reassigned, while the underlying dataframe remains owned by its backend.
|
|
19
|
+
Connectors construct this object; it deliberately contains no profiling or
|
|
20
|
+
source-specific behavior.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
dataframe: The loaded pandas or Polars dataframe.
|
|
24
|
+
backend: The dataframe backend identifier, either ``"pandas"`` or
|
|
25
|
+
``"polars"``.
|
|
26
|
+
schema: Ordered schema inferred from the dataframe.
|
|
27
|
+
metadata: Read-only descriptive metadata supplied by the connector.
|
|
28
|
+
row_count: Number of rows in the dataframe.
|
|
29
|
+
column_count: Number of columns in the dataframe.
|
|
30
|
+
dtypes: Read-only mapping from column name to dtype string.
|
|
31
|
+
source: Original file path when loaded from a file, otherwise ``None``.
|
|
32
|
+
file_size: File size in bytes when loaded from a file, otherwise
|
|
33
|
+
``None``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
dataframe: Any
|
|
37
|
+
backend: str
|
|
38
|
+
schema: DatasetSchema
|
|
39
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
40
|
+
row_count: int = 0
|
|
41
|
+
column_count: int = 0
|
|
42
|
+
dtypes: Mapping[str, str] = field(default_factory=dict)
|
|
43
|
+
source: str | None = None
|
|
44
|
+
file_size: int | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
"""Freeze mapping fields so dataset descriptors stay immutable."""
|
|
48
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
|
|
49
|
+
object.__setattr__(self, "dtypes", MappingProxyType(dict(self.dtypes)))
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dataframe(
|
|
53
|
+
cls,
|
|
54
|
+
dataframe: Any,
|
|
55
|
+
*,
|
|
56
|
+
backend: str,
|
|
57
|
+
source: str | None = None,
|
|
58
|
+
file_size: int | None = None,
|
|
59
|
+
metadata: Mapping[str, object] | None = None,
|
|
60
|
+
) -> Dataset:
|
|
61
|
+
"""Create a normalized dataset from a dataframe backend object.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
dataframe: A pandas or Polars dataframe.
|
|
65
|
+
backend: Identifier for the dataframe backend.
|
|
66
|
+
source: Original local file path, if any.
|
|
67
|
+
file_size: Source file size in bytes, if known.
|
|
68
|
+
metadata: Connector-provided descriptive metadata.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A normalized dataset with an inferred schema and dtype mapping.
|
|
72
|
+
"""
|
|
73
|
+
columns = tuple(str(column) for column in dataframe.columns)
|
|
74
|
+
dtype_values = tuple(str(dtype) for dtype in dataframe.dtypes)
|
|
75
|
+
dtypes = dict(zip(columns, dtype_values, strict=True))
|
|
76
|
+
schema = DatasetSchema(
|
|
77
|
+
columns=tuple(
|
|
78
|
+
ColumnSchema(name=name, dtype=dtype) for name, dtype in dtypes.items()
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return cls(
|
|
82
|
+
dataframe=dataframe,
|
|
83
|
+
backend=backend,
|
|
84
|
+
schema=schema,
|
|
85
|
+
metadata=metadata or {},
|
|
86
|
+
row_count=len(dataframe),
|
|
87
|
+
column_count=len(columns),
|
|
88
|
+
dtypes=dtypes,
|
|
89
|
+
source=source,
|
|
90
|
+
file_size=file_size,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def preview(self, rows: int = 5) -> Any:
|
|
94
|
+
"""Return the first requested number of rows from the dataframe.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
rows: Number of rows to return. Must be non-negative.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A dataframe of the same backend containing the requested head rows.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If ``rows`` is negative.
|
|
104
|
+
"""
|
|
105
|
+
if rows < 0:
|
|
106
|
+
raise ValueError("Preview row count must be non-negative.")
|
|
107
|
+
return self.dataframe.head(rows)
|