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.
Files changed (45) hide show
  1. featuresmith/__init__.py +7 -0
  2. featuresmith/api.py +180 -0
  3. featuresmith/connectors/__init__.py +18 -0
  4. featuresmith/connectors/_paths.py +25 -0
  5. featuresmith/connectors/base.py +54 -0
  6. featuresmith/connectors/csv_connector.py +44 -0
  7. featuresmith/connectors/dataframe_connector.py +40 -0
  8. featuresmith/connectors/excel_connector.py +45 -0
  9. featuresmith/connectors/parquet_connector.py +44 -0
  10. featuresmith/connectors/registry.py +55 -0
  11. featuresmith/core/__init__.py +50 -0
  12. featuresmith/core/dataset.py +107 -0
  13. featuresmith/core/exceptions.py +17 -0
  14. featuresmith/core/profile_result.py +412 -0
  15. featuresmith/core/rule_finding.py +44 -0
  16. featuresmith/core/rule_result.py +49 -0
  17. featuresmith/core/schema.py +34 -0
  18. featuresmith/profiling/__init__.py +5 -0
  19. featuresmith/profiling/categorical.py +108 -0
  20. featuresmith/profiling/correlation.py +67 -0
  21. featuresmith/profiling/datetime.py +105 -0
  22. featuresmith/profiling/duplicates.py +48 -0
  23. featuresmith/profiling/missing.py +70 -0
  24. featuresmith/profiling/numeric.py +248 -0
  25. featuresmith/profiling/profiler.py +209 -0
  26. featuresmith/profiling/quality.py +29 -0
  27. featuresmith/profiling/summary.py +117 -0
  28. featuresmith/profiling/text.py +109 -0
  29. featuresmith/py.typed +1 -0
  30. featuresmith/rules/__init__.py +27 -0
  31. featuresmith/rules/base.py +69 -0
  32. featuresmith/rules/cardinality.py +95 -0
  33. featuresmith/rules/constants.py +115 -0
  34. featuresmith/rules/correlation.py +77 -0
  35. featuresmith/rules/duplicates.py +76 -0
  36. featuresmith/rules/engine.py +156 -0
  37. featuresmith/rules/leakage.py +93 -0
  38. featuresmith/rules/missing.py +83 -0
  39. featuresmith/rules/outliers.py +113 -0
  40. featuresmith/rules/registry.py +81 -0
  41. featuresmith_core-0.1.0.dist-info/METADATA +50 -0
  42. featuresmith_core-0.1.0.dist-info/RECORD +45 -0
  43. featuresmith_core-0.1.0.dist-info/WHEEL +5 -0
  44. featuresmith_core-0.1.0.dist-info/licenses/LICENSE +187 -0
  45. featuresmith_core-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """Featuresmith core package."""
2
+
3
+ from featuresmith.api import analyze, load, profile
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["analyze", "load", "profile"]
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)