mlschema 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.
- mlschema/__init__.py +33 -0
- mlschema/core/__init__.py +33 -0
- mlschema/core/app/__init__.py +5 -0
- mlschema/core/app/registry.py +96 -0
- mlschema/core/app/service.py +118 -0
- mlschema/core/app/strategy.py +104 -0
- mlschema/core/domain/__init__.py +3 -0
- mlschema/core/domain/base_field.py +23 -0
- mlschema/core/exceptions/__init__.py +22 -0
- mlschema/core/exceptions/_base.py +81 -0
- mlschema/core/exceptions/registry.py +73 -0
- mlschema/core/exceptions/service.py +81 -0
- mlschema/core/util/__init__.py +3 -0
- mlschema/core/util/dtypes.py +8 -0
- mlschema/mls.py +68 -0
- mlschema/py.typed +0 -0
- mlschema/strategies/__init__.py +41 -0
- mlschema/strategies/app/__init__.py +13 -0
- mlschema/strategies/app/boolean_strategy.py +32 -0
- mlschema/strategies/app/category_strategy.py +54 -0
- mlschema/strategies/app/date_strategy.py +42 -0
- mlschema/strategies/app/number_strategy.py +61 -0
- mlschema/strategies/app/text_strategy.py +43 -0
- mlschema/strategies/domain/__init__.py +15 -0
- mlschema/strategies/domain/boolean_field.py +11 -0
- mlschema/strategies/domain/category_field.py +23 -0
- mlschema/strategies/domain/date_field.py +38 -0
- mlschema/strategies/domain/field_types.py +9 -0
- mlschema/strategies/domain/number_field.py +43 -0
- mlschema/strategies/domain/text_field.py +61 -0
- mlschema-0.1.0.dist-info/METADATA +97 -0
- mlschema-0.1.0.dist-info/RECORD +34 -0
- mlschema-0.1.0.dist-info/WHEEL +4 -0
- mlschema-0.1.0.dist-info/licenses/LICENSE +21 -0
mlschema/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Production-grade orchestration for translating **pandas** DataFrames into
|
|
2
|
+
validated JSON field schemas.
|
|
3
|
+
|
|
4
|
+
The package exports a single façade—:class:`mlschema.MLSchema`
|
|
5
|
+
(alias :pydata:`mlform.MLForm`) which wraps the internal *Service/Registry*
|
|
6
|
+
subsystem. Client code typically:
|
|
7
|
+
|
|
8
|
+
1. **Registers** concrete field strategies.
|
|
9
|
+
2. **Builds** a JSON-serialisable schema from a DataFrame.
|
|
10
|
+
|
|
11
|
+
Public surface:
|
|
12
|
+
* `MLSchema` — canonical entry point
|
|
13
|
+
* `mlschema.core.Strategy` — extension contract (advanced)
|
|
14
|
+
* `mlschema.core.BaseField` — Pydantic base model (advanced)
|
|
15
|
+
* All runtime errors derive from `mlschema.core.MLSchemaError`.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
```python
|
|
19
|
+
from mlschema import MLSchema
|
|
20
|
+
from mlschema.strategies import NumberStrategy
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
ms = MLSchema()
|
|
24
|
+
ms.register(NumberStrategy())
|
|
25
|
+
|
|
26
|
+
df = pd.DataFrame({"age": [22, 37, 29]})
|
|
27
|
+
schema = ms.build(df)
|
|
28
|
+
```
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .mls import MLSchema
|
|
32
|
+
|
|
33
|
+
__all__ = ["MLSchema"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Core abstractions and error contracts for **MLSchema**.
|
|
2
|
+
|
|
3
|
+
This module defines the *extension surface* on which all custom behaviour
|
|
4
|
+
is built. Integrators subclass the abstractions below to introduce new data
|
|
5
|
+
types or override default processing logic, and they trap the accompanying
|
|
6
|
+
exceptions to maintain deterministic error handling across the pipeline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from mlschema.core.app import Strategy
|
|
10
|
+
from mlschema.core.domain import BaseField
|
|
11
|
+
from mlschema.core.exceptions import (
|
|
12
|
+
EmptyDataFrameError,
|
|
13
|
+
FallbackStrategyMissingError,
|
|
14
|
+
FieldRegistryError,
|
|
15
|
+
FieldServiceError,
|
|
16
|
+
InvalidValueError,
|
|
17
|
+
MLSchemaError,
|
|
18
|
+
StrategyDtypeAlreadyRegisteredError,
|
|
19
|
+
StrategyNameAlreadyRegisteredError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Strategy",
|
|
24
|
+
"BaseField",
|
|
25
|
+
"MLSchemaError",
|
|
26
|
+
"InvalidValueError",
|
|
27
|
+
"FieldRegistryError",
|
|
28
|
+
"FieldServiceError",
|
|
29
|
+
"EmptyDataFrameError",
|
|
30
|
+
"FallbackStrategyMissingError",
|
|
31
|
+
"StrategyDtypeAlreadyRegisteredError",
|
|
32
|
+
"StrategyNameAlreadyRegisteredError",
|
|
33
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from numpy import dtype as np_dtype
|
|
4
|
+
|
|
5
|
+
from mlschema.core.app.strategy import Strategy
|
|
6
|
+
from mlschema.core.exceptions import (
|
|
7
|
+
StrategyDtypeAlreadyRegisteredError,
|
|
8
|
+
StrategyNameAlreadyRegisteredError,
|
|
9
|
+
)
|
|
10
|
+
from mlschema.core.util import normalize_dtype
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Registry:
|
|
14
|
+
"""Manages the lifecycle of registered *Strategy* instances."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
"""Initialize empty internal containers.
|
|
18
|
+
|
|
19
|
+
The registry maintains two parallel indexes for efficient lookups:
|
|
20
|
+
|
|
21
|
+
* ``_by_name`` - Maps logical type names (e.g., "number") to Strategy instances
|
|
22
|
+
* ``_by_dtype`` - Maps normalized dtype names (e.g., "float64") to Strategy instances
|
|
23
|
+
|
|
24
|
+
Both structures are kept coherent in each registration, update, or
|
|
25
|
+
removal operation. The registry is not thread-safe by design (the
|
|
26
|
+
expected usage is write-once, read-many in inference processes).
|
|
27
|
+
"""
|
|
28
|
+
self._by_name: dict[str, Strategy] = {}
|
|
29
|
+
self._by_dtype: dict[str, Strategy] = {}
|
|
30
|
+
|
|
31
|
+
def register(self, strategy: Strategy, *, overwrite: bool = False) -> None:
|
|
32
|
+
"""Register a new strategy.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
strategy: Instance of :class:`Strategy` to register.
|
|
36
|
+
overwrite: If True, an existing registration with the same type_name or dtype will be replaced instead of raising an exception.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
StrategyNameAlreadyRegisteredError: If a strategy already exists for that type_name and overwrite is False.
|
|
40
|
+
StrategyDtypeAlreadyRegisteredError: If a strategy already exists for any of its dtype and overwrite is False.
|
|
41
|
+
"""
|
|
42
|
+
if not overwrite and strategy.type_name in self._by_name:
|
|
43
|
+
raise StrategyNameAlreadyRegisteredError(strategy.type_name)
|
|
44
|
+
|
|
45
|
+
normalized_keys = set(map(normalize_dtype, strategy.dtypes))
|
|
46
|
+
|
|
47
|
+
for key in normalized_keys:
|
|
48
|
+
if not overwrite and key in self._by_dtype:
|
|
49
|
+
raise StrategyDtypeAlreadyRegisteredError(key)
|
|
50
|
+
|
|
51
|
+
self._by_name[strategy.type_name] = strategy
|
|
52
|
+
|
|
53
|
+
for key in normalized_keys:
|
|
54
|
+
self._by_dtype[key] = strategy
|
|
55
|
+
|
|
56
|
+
def update(self, strategy: Strategy) -> None:
|
|
57
|
+
"""Replace the existing strategy with the same ``type_name``.
|
|
58
|
+
|
|
59
|
+
Equivalent to calling :meth:`register` with ``overwrite=True``.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
strategy: Instance of :class:`Strategy` to update.
|
|
63
|
+
"""
|
|
64
|
+
self.register(strategy, overwrite=True)
|
|
65
|
+
|
|
66
|
+
def unregister(self, type_name: str) -> None:
|
|
67
|
+
"""Remove a strategy from the registry.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
type_name: The logical identifier of the strategy to remove.
|
|
71
|
+
"""
|
|
72
|
+
strat = self._by_name.pop(type_name, None)
|
|
73
|
+
if strat is not None:
|
|
74
|
+
self._by_dtype = {k: v for k, v in self._by_dtype.items() if v is not strat}
|
|
75
|
+
|
|
76
|
+
def strategy_for_name(self, type_name: str) -> Strategy | None:
|
|
77
|
+
"""Return the strategy associated with ``type_name`` or ``None``.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
type_name: Logical identifier of the field type to look up.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Strategy instance for the given type name, or None if not found.
|
|
84
|
+
"""
|
|
85
|
+
return self._by_name.get(type_name)
|
|
86
|
+
|
|
87
|
+
def strategy_for_dtype(self, dtype: str | np_dtype) -> Strategy | None:
|
|
88
|
+
"""Return the strategy capable of handling ``dtype`` or ``None``.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
dtype: NumPy dtype string or dtype object to look up.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Strategy instance that handles the given dtype, or None if not found.
|
|
95
|
+
"""
|
|
96
|
+
return self._by_dtype.get(normalize_dtype(dtype))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pandas import DataFrame, Series
|
|
4
|
+
|
|
5
|
+
from mlschema.core.app.registry import Registry
|
|
6
|
+
from mlschema.core.exceptions.service import (
|
|
7
|
+
EmptyDataFrameError,
|
|
8
|
+
FallbackStrategyMissingError,
|
|
9
|
+
)
|
|
10
|
+
from mlschema.core.util import normalize_dtype
|
|
11
|
+
|
|
12
|
+
from .strategy import Strategy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Service:
|
|
16
|
+
"""Translates a :class:`pandas.DataFrame` to the JSON spec consumed by the front-end.
|
|
17
|
+
|
|
18
|
+
The Service class acts as the main orchestrator for converting pandas DataFrame
|
|
19
|
+
structures into JSON schema payloads. It uses a registry-based strategy pattern
|
|
20
|
+
to handle different data types and their corresponding schema representations.
|
|
21
|
+
|
|
22
|
+
The service generates JSON payloads in the format:
|
|
23
|
+
{"inputs": [...], "outputs": [...]}
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
_registry: Internal registry that manages field strategies for different data types.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
"""Initialize the Service with an empty Registry."""
|
|
31
|
+
self._registry = Registry()
|
|
32
|
+
|
|
33
|
+
def register(self, strategy: Strategy) -> None:
|
|
34
|
+
"""Register a new field strategy.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
strategy: Instance of :class:`Strategy` to register.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
StrategyNameAlreadyRegisteredError: If a strategy with the same type_name already exists.
|
|
41
|
+
StrategyDtypeAlreadyRegisteredError: If a strategy with the same dtype already exists.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
self._registry.register(strategy)
|
|
45
|
+
|
|
46
|
+
def unregister(self, strategy: Strategy) -> None:
|
|
47
|
+
"""Unregister a previously registered strategy.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
strategy: Instance of :class:`Strategy` to unregister.
|
|
51
|
+
"""
|
|
52
|
+
self._registry.unregister(strategy.type_name)
|
|
53
|
+
|
|
54
|
+
def update(self, strategy: Strategy) -> None:
|
|
55
|
+
"""Update an already registered strategy.
|
|
56
|
+
|
|
57
|
+
If the strategy doesn't exist, it's registered as new.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
strategy: Instance of :class:`Strategy` to update.
|
|
61
|
+
"""
|
|
62
|
+
self._registry.update(strategy)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------ #
|
|
65
|
+
# Internal helpers #
|
|
66
|
+
# ------------------------------------------------------------------ #
|
|
67
|
+
def _field_payload(self, series: Series) -> dict:
|
|
68
|
+
"""Generate the schema for a specific column.
|
|
69
|
+
|
|
70
|
+
If the series dtype is not associated with any strategy,
|
|
71
|
+
a fallback strategy under the ``type_name`` "text" is attempted.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
series: Pandas series to inspect.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dictionary with the column schema.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
FallbackStrategyMissingError: If no fallback strategy exists.
|
|
81
|
+
"""
|
|
82
|
+
dtype = normalize_dtype(series.dtype)
|
|
83
|
+
strat = self._registry.strategy_for_dtype(
|
|
84
|
+
dtype
|
|
85
|
+
) or self._registry.strategy_for_name("text")
|
|
86
|
+
if strat is None:
|
|
87
|
+
raise FallbackStrategyMissingError(dtype)
|
|
88
|
+
return strat.build_dict(series)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# Public API #
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
def _schema_payload(self, df: DataFrame) -> list[dict]:
|
|
94
|
+
"""Build the list of schemas for each column.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
df: Source DataFrame.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Ordered list of schemas.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
EmptyDataFrameError: If the DataFrame has no columns or is empty.
|
|
104
|
+
"""
|
|
105
|
+
if df.columns.empty or df.empty:
|
|
106
|
+
raise EmptyDataFrameError(df)
|
|
107
|
+
return [self._field_payload(col) for _, col in df.items()]
|
|
108
|
+
|
|
109
|
+
def build_schema(self, df: DataFrame) -> dict[str, list[dict]]:
|
|
110
|
+
"""Return the final payload ready for injection into the front-end.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
df: Source DataFrame.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
JSON payload with the schema of each column.
|
|
117
|
+
"""
|
|
118
|
+
return {"inputs": self._schema_payload(df), "outputs": []}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from numpy import dtype as np_dtype
|
|
6
|
+
from pandas import Series
|
|
7
|
+
|
|
8
|
+
from mlschema.core.domain import BaseField
|
|
9
|
+
from mlschema.core.util import normalize_dtype
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Strategy:
|
|
13
|
+
"""
|
|
14
|
+
Abstract base class for all MLSchema field strategies.
|
|
15
|
+
|
|
16
|
+
Each concrete strategy maps a *single* pandas dtype (or group of dtypes) to a
|
|
17
|
+
validated JSON field specification. Strategies are **opt-in**: they influence
|
|
18
|
+
schema generation only after being registered via `MLSchema.register()`.
|
|
19
|
+
|
|
20
|
+
Usage contract:
|
|
21
|
+
* Do **not** mutate the incoming `Series`; treat it as read-only.
|
|
22
|
+
* Subclasses should override `attributes_from_series()` to emit extra
|
|
23
|
+
metadata, but must avoid the reserved keys: `"title"`, `"type"`,
|
|
24
|
+
`"required"`, `"description"`.
|
|
25
|
+
* Registration is idempotent—duplicate `type_name`'s must be replaced via
|
|
26
|
+
`MLSchema.update()`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
type_name: str,
|
|
33
|
+
schema_cls: type[BaseField],
|
|
34
|
+
dtypes: Sequence[str | np_dtype],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
type_name: Identifier for the strategy type.
|
|
40
|
+
schema_cls: Pydantic class that models the field.
|
|
41
|
+
dtypes: Sequence of ``dtype`` (instances or names) to which the strategy applies.
|
|
42
|
+
"""
|
|
43
|
+
self._type_name: str = type_name
|
|
44
|
+
self._schema_cls: type[BaseField] = schema_cls
|
|
45
|
+
self._dtypes: tuple[str, ...] = tuple(
|
|
46
|
+
normalize_dtype(dtype) for dtype in dtypes
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def type_name(self) -> str:
|
|
51
|
+
"""Identifier for the strategy type."""
|
|
52
|
+
return self._type_name
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def schema_cls(self) -> type[BaseField]:
|
|
56
|
+
"""Pydantic class used to serialize the schema."""
|
|
57
|
+
return self._schema_cls
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def dtypes(self) -> tuple[str, ...]:
|
|
61
|
+
"""Tuple of supported ``dtype`` names."""
|
|
62
|
+
return self._dtypes
|
|
63
|
+
|
|
64
|
+
def attributes_from_series(self, series: Series) -> dict:
|
|
65
|
+
"""Calculate field-specific attributes.
|
|
66
|
+
|
|
67
|
+
This method can be overridden by subclasses to add
|
|
68
|
+
implementation-specific metadata to the schema.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
series: Dataframe column to analyze.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dictionary with additional attributes; never includes the standard keys ``title``, ``type``, ``required``, ``description``.
|
|
75
|
+
"""
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
def build_dict(self, series: Series) -> dict:
|
|
79
|
+
"""Create the JSON representation of the schema.
|
|
80
|
+
|
|
81
|
+
Combines the standard attributes with those returned by `attributes_from_series` and serializes the result with the associated Pydantic class.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
series: Dataframe column to analyze.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
JSON with the field schema.
|
|
88
|
+
"""
|
|
89
|
+
base_attrs: dict = {
|
|
90
|
+
"title": series.name,
|
|
91
|
+
"required": not series.isna().any(),
|
|
92
|
+
"description": None,
|
|
93
|
+
"type": self.type_name,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Incorporate implementation-specific attributes
|
|
97
|
+
base_attrs.update(self.attributes_from_series(series))
|
|
98
|
+
|
|
99
|
+
# Instantiate the Pydantic class and dump to JSON
|
|
100
|
+
return self._schema_cls(**base_attrs).model_dump(
|
|
101
|
+
mode="json",
|
|
102
|
+
exclude_unset=False,
|
|
103
|
+
exclude_none=True,
|
|
104
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseField(BaseModel):
|
|
9
|
+
"""Standard metadata present in **all** fields.
|
|
10
|
+
|
|
11
|
+
Extend this class to define custom field types.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
title: Human-readable field identifier (1-100 characters).
|
|
15
|
+
description: Optional description (max. 500 characters).
|
|
16
|
+
required: True if the original column contains no null values.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid", frozen=False)
|
|
20
|
+
|
|
21
|
+
title: Annotated[str, Field(min_length=1, max_length=100)]
|
|
22
|
+
description: Annotated[str | None, Field(max_length=500)] = None
|
|
23
|
+
required: bool = True
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from mlschema.core.exceptions._base import InvalidValueError, MLSchemaError
|
|
2
|
+
from mlschema.core.exceptions.registry import (
|
|
3
|
+
FieldRegistryError,
|
|
4
|
+
StrategyDtypeAlreadyRegisteredError,
|
|
5
|
+
StrategyNameAlreadyRegisteredError,
|
|
6
|
+
)
|
|
7
|
+
from mlschema.core.exceptions.service import (
|
|
8
|
+
EmptyDataFrameError,
|
|
9
|
+
FallbackStrategyMissingError,
|
|
10
|
+
FieldServiceError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MLSchemaError",
|
|
15
|
+
"InvalidValueError",
|
|
16
|
+
"FieldRegistryError",
|
|
17
|
+
"FieldServiceError",
|
|
18
|
+
"StrategyNameAlreadyRegisteredError",
|
|
19
|
+
"StrategyDtypeAlreadyRegisteredError",
|
|
20
|
+
"EmptyDataFrameError",
|
|
21
|
+
"FallbackStrategyMissingError",
|
|
22
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MLSchemaError(Exception):
|
|
7
|
+
"""Project-root for every **mlschema** runtime failure.
|
|
8
|
+
|
|
9
|
+
All domain-specific exceptions ultimately derive from this class,
|
|
10
|
+
enabling both narrow and broad interception patterns:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
try:
|
|
14
|
+
schema = ms.build(df)
|
|
15
|
+
except MLSchemaError as exc: # catch-all
|
|
16
|
+
logger.error("Schema failure: %s", exc, exc_info=True)
|
|
17
|
+
raise HTTPException(422, detail=str(exc)) from exc
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
context: Optional, machine-friendly diagnostics (e.g., offending
|
|
22
|
+
`dtype`, column name, strategy ID). Contents are *stable* only
|
|
23
|
+
for the public leaf exceptions; treat additional keys as
|
|
24
|
+
informational.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, message: str | None = None, *, context: dict[str, Any] | None = None
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message or self.__class__.__name__)
|
|
31
|
+
self.context: dict[str, Any] | None = context
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidValueError(MLSchemaError, ValueError):
|
|
35
|
+
"""Standard signal for configuration or user-input violations.
|
|
36
|
+
|
|
37
|
+
Raised when a supplied argument, configuration value, or runtime
|
|
38
|
+
artefact fails validation. Subclasses narrow the scope to
|
|
39
|
+
specific domains (e.g., *registry* vs. *service* faults).
|
|
40
|
+
|
|
41
|
+
Args
|
|
42
|
+
----
|
|
43
|
+
param: Logical argument name that triggered the failure
|
|
44
|
+
(``"dtype"``, ``"type_name"``, …).
|
|
45
|
+
value: Offending value already *normalised* by the caller.
|
|
46
|
+
message: Human-readable description. If *None*, a neutral default is
|
|
47
|
+
auto-generated.
|
|
48
|
+
context: Arbitrary diagnostics for observability pipelines
|
|
49
|
+
(e.g., ``{"strategy": "NumberStrategy"}``).
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
param: Same as the *param* constructor argument.
|
|
53
|
+
value: Same as the *value* constructor argument.
|
|
54
|
+
context: Same as the *context* constructor argument.
|
|
55
|
+
Same as the *context* constructor argument.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
```python
|
|
60
|
+
if dtype_key in registry:
|
|
61
|
+
raise InvalidValueError(
|
|
62
|
+
param="dtype",
|
|
63
|
+
value=dtype_key,
|
|
64
|
+
message=f"dtype {dtype_key!r} already mapped",
|
|
65
|
+
context={"registered_strategy": registry[dtype_key]},
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
param: str,
|
|
73
|
+
value: Any,
|
|
74
|
+
message: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
context: dict[str, Any] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.param: str = param
|
|
79
|
+
self.value: Any = value
|
|
80
|
+
default = f'Invalid value for "{param}": {value!r}.'
|
|
81
|
+
super().__init__(message or default, context=context)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# mlschema/core/exceptions/registry.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mlschema.core.exceptions._base import InvalidValueError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FieldRegistryError(InvalidValueError):
|
|
10
|
+
"""Domain-root for **registry-layer** validation failures.
|
|
11
|
+
|
|
12
|
+
Triggered when an operation on the strategy registry receives an
|
|
13
|
+
invalid, conflicting, or otherwise disallowed value.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
param: Logical argument name that caused the fault
|
|
17
|
+
value: Offending value (already normalised by the caller).
|
|
18
|
+
message: Human-readable description. If *None*, a neutral default is autogenerated.
|
|
19
|
+
context: Arbitrary diagnostics—module, strategy ID, etc.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
param: Same as the *param* constructor argument.
|
|
23
|
+
value: Same as the *value* constructor argument.
|
|
24
|
+
context: Same as the *context* constructor argument.
|
|
25
|
+
|
|
26
|
+
Example
|
|
27
|
+
-------
|
|
28
|
+
```python
|
|
29
|
+
if type_name in registry:
|
|
30
|
+
raise FieldRegistryError(
|
|
31
|
+
param="type_name",
|
|
32
|
+
value=type_name,
|
|
33
|
+
message=f"Strategy {type_name!r} already exists",
|
|
34
|
+
context={"existing_cls": registry[type_name]},
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
param: str,
|
|
42
|
+
value: Any,
|
|
43
|
+
message: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
context: dict[str, Any] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
# build default fallback only if the caller omitted a custom message
|
|
48
|
+
default_msg = f"Registry received an invalid value for {param!r}: {value!r}."
|
|
49
|
+
super().__init__(param, value, message or default_msg, context=context)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StrategyNameAlreadyRegisteredError(FieldRegistryError):
|
|
53
|
+
"""Conflict: two strategies contend for the same **type_name**."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, type_name: str) -> None:
|
|
56
|
+
super().__init__(
|
|
57
|
+
param="type_name",
|
|
58
|
+
value=type_name,
|
|
59
|
+
message=f'Strategy for type_name "{type_name}" is already registered.',
|
|
60
|
+
context={"offender": type_name},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StrategyDtypeAlreadyRegisteredError(FieldRegistryError):
|
|
65
|
+
"""Conflict: two strategies contend for the same **dtype** key."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, dtype_key: str) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
param="dtype",
|
|
70
|
+
value=dtype_key,
|
|
71
|
+
message=f'Strategy for dtype "{dtype_key}" is already registered.',
|
|
72
|
+
context={"offender": dtype_key},
|
|
73
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# mlschema/core/exceptions/service.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._base import InvalidValueError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FieldServiceError(InvalidValueError):
|
|
10
|
+
"""Domain-root for **service-layer** validation failures.
|
|
11
|
+
|
|
12
|
+
Triggered when runtime data or configuration supplied to the *Service*
|
|
13
|
+
component is missing, malformed, or otherwise unusable.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
param: Logical argument name that caused the fault
|
|
17
|
+
(e.g., ``"dataframe"``, ``"dtype"``, ``"fallback_strategy"``).
|
|
18
|
+
value: Offending value—typically the incoming DataFrame, a dtype string,
|
|
19
|
+
or a strategy identifier.
|
|
20
|
+
message: Human-readable description. If *None*, a neutral default is
|
|
21
|
+
autogenerated.
|
|
22
|
+
context: Arbitrary diagnostics for logs or metrics
|
|
23
|
+
(row/column counts, offending dtype, etc.).
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
param: Mirrors the *param* constructor argument.
|
|
27
|
+
value: Mirrors the *value* constructor argument.
|
|
28
|
+
context: Mirrors the *context* constructor argument.
|
|
29
|
+
|
|
30
|
+
Example
|
|
31
|
+
-------
|
|
32
|
+
```python
|
|
33
|
+
if df.empty:
|
|
34
|
+
raise FieldServiceError(
|
|
35
|
+
param="dataframe",
|
|
36
|
+
value=df,
|
|
37
|
+
message="Input DataFrame is empty",
|
|
38
|
+
context={"rows": 0, "cols": 0},
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
param: str,
|
|
46
|
+
value: Any,
|
|
47
|
+
message: str | None = None,
|
|
48
|
+
*,
|
|
49
|
+
context: dict[str, Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
default = f"Service received an invalid value for {param!r}: {value!r}."
|
|
52
|
+
super().__init__(param, value, message or default, context=context)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EmptyDataFrameError(FieldServiceError):
|
|
56
|
+
"""Conflict: Service received a DataFrame with **zero** rows *or* columns."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, df) -> None:
|
|
59
|
+
super().__init__(
|
|
60
|
+
param="dataframe",
|
|
61
|
+
value=df,
|
|
62
|
+
message="DataFrame contains no columns or rows.",
|
|
63
|
+
context={"rows": len(df.index), "cols": len(df.columns)},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FallbackStrategyMissingError(FieldServiceError):
|
|
68
|
+
"""
|
|
69
|
+
Conflict: No registered strategy matches the column dtype, and the fallback ``"text"`` strategy is absent.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, dtype_str: str) -> None:
|
|
73
|
+
super().__init__(
|
|
74
|
+
param="dtype",
|
|
75
|
+
value=dtype_str,
|
|
76
|
+
message=(
|
|
77
|
+
f'No strategy registered for dtype "{dtype_str}"; attempted '
|
|
78
|
+
'fallback "text" but it is not available.'
|
|
79
|
+
),
|
|
80
|
+
context={"dtype": dtype_str},
|
|
81
|
+
)
|