SIENTIAPDE-1579: Switched from replace logic to validation + mapping
This commit is contained in:
@@ -17,14 +17,35 @@ NORMALIZATION = 'Normalization'
|
|||||||
FEATURE_CREATION = 'Feature Creation'
|
FEATURE_CREATION = 'Feature Creation'
|
||||||
LAG_CREATION = 'Lag Creation'
|
LAG_CREATION = 'Lag Creation'
|
||||||
|
|
||||||
|
# Allowed frontend date formats and their strftime equivalents (single source of truth)
|
||||||
|
FRONTEND_DATE_FORMAT_TO_STRFTIME = {
|
||||||
|
'dd/MM/yyyy HH:mm:ss': '%d/%m/%Y %H:%M:%S',
|
||||||
|
'MM/dd/yyyy HH:mm:ss': '%m/%d/%Y %H:%M:%S',
|
||||||
|
'yyyy/MM/dd HH:mm:ss': '%Y/%m/%d %H:%M:%S',
|
||||||
|
'dd-MM-yyyy HH:mm:ss': '%d-%m-%Y %H:%M:%S',
|
||||||
|
'MM-dd-yyyy HH:mm:ss': '%m-%d-%Y %H:%M:%S',
|
||||||
|
'yyyy-MM-dd HH:mm:ss': '%Y-%m-%d %H:%M:%S',
|
||||||
|
}
|
||||||
|
ALLOWED_FRONTEND_DATE_FORMATS = frozenset(FRONTEND_DATE_FORMAT_TO_STRFTIME.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_frontend_date_format(fmt: str | None) -> None:
|
||||||
|
"""Raise ValueError if fmt is set and not one of the allowed frontend date formats."""
|
||||||
|
if not fmt or not fmt.strip():
|
||||||
|
return
|
||||||
|
if fmt not in ALLOWED_FRONTEND_DATE_FORMATS:
|
||||||
|
allowed = ', '.join(sorted(ALLOWED_FRONTEND_DATE_FORMATS))
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid date_format "{fmt}". Allowed formats: {allowed}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _frontend_date_format_to_strftime(fmt: str | None) -> str | None:
|
def _frontend_date_format_to_strftime(fmt: str | None) -> str | None:
|
||||||
"""Convert front-end date format (e.g. dd/MM/yyyy HH:mm:ss) to Python strftime."""
|
"""Convert front-end date format to Python strftime. Validates format; returns None for empty."""
|
||||||
if not fmt:
|
if not fmt:
|
||||||
return None
|
return None
|
||||||
out = fmt.replace('yyyy', '%Y').replace('MM', '%m').replace('dd', '%d')
|
validate_frontend_date_format(fmt)
|
||||||
out = out.replace('HH', '%H').replace('mm', '%M').replace('ss', '%S')
|
return FRONTEND_DATE_FORMAT_TO_STRFTIME[fmt]
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class LinearRegressionModel(BaseEstimator, TransformerMixin):
|
class LinearRegressionModel(BaseEstimator, TransformerMixin):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from model_manager.sientia.models import validate_frontend_date_format
|
||||||
|
|
||||||
# Model name constants
|
# Model name constants
|
||||||
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
||||||
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
||||||
@@ -217,6 +219,7 @@ class TrainModelParams:
|
|||||||
self._validate_intervals_and_dates()
|
self._validate_intervals_and_dates()
|
||||||
self._validate_limits()
|
self._validate_limits()
|
||||||
self._validate_required_strings()
|
self._validate_required_strings()
|
||||||
|
self._validate_date_format()
|
||||||
|
|
||||||
def _validate_numeric_ranges(self) -> None:
|
def _validate_numeric_ranges(self) -> None:
|
||||||
"""Validate numeric parameters are within acceptable ranges."""
|
"""Validate numeric parameters are within acceptable ranges."""
|
||||||
@@ -327,3 +330,8 @@ class TrainModelParams:
|
|||||||
|
|
||||||
if not self.experiment_name.strip():
|
if not self.experiment_name.strip():
|
||||||
raise ValueError('experiment_name cannot be empty or whitespace')
|
raise ValueError('experiment_name cannot be empty or whitespace')
|
||||||
|
|
||||||
|
def _validate_date_format(self) -> None:
|
||||||
|
"""Validate date_format is one of the allowed frontend formats when set."""
|
||||||
|
if self.date_format:
|
||||||
|
validate_frontend_date_format(self.date_format)
|
||||||
|
|||||||
@@ -7,34 +7,56 @@ import pandas as pd
|
|||||||
from pytest import raises
|
from pytest import raises
|
||||||
|
|
||||||
from model_manager.sientia.models import (
|
from model_manager.sientia.models import (
|
||||||
|
ALLOWED_FRONTEND_DATE_FORMATS,
|
||||||
|
FRONTEND_DATE_FORMAT_TO_STRFTIME,
|
||||||
DataPreprocessor,
|
DataPreprocessor,
|
||||||
LinearRegressionModel,
|
LinearRegressionModel,
|
||||||
_frontend_date_format_to_strftime,
|
_frontend_date_format_to_strftime,
|
||||||
|
validate_frontend_date_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestFrontendDateFormatToStrftime:
|
class TestFrontendDateFormatToStrftime:
|
||||||
"""Tests for _frontend_date_format_to_strftime (models module)."""
|
"""Tests for _frontend_date_format_to_strftime (models module)."""
|
||||||
|
|
||||||
def test_none_or_empty_returns_none_or_empty(self):
|
def test_none_or_empty_returns_none(self):
|
||||||
"""None or empty string returns None or falsy."""
|
"""None or empty string returns None."""
|
||||||
assert _frontend_date_format_to_strftime(None) is None
|
assert _frontend_date_format_to_strftime(None) is None
|
||||||
assert _frontend_date_format_to_strftime('') is None
|
assert _frontend_date_format_to_strftime('') is None
|
||||||
|
|
||||||
def test_dd_mm_yyyy_hh_mm_ss(self):
|
def test_all_six_allowed_formats_convert_correctly(self):
|
||||||
"""Converts dd/MM/yyyy HH:mm:ss to strftime."""
|
"""All allowed frontend formats map to expected strftime."""
|
||||||
result = _frontend_date_format_to_strftime('dd/MM/yyyy HH:mm:ss')
|
for frontend_fmt, strftime_fmt in FRONTEND_DATE_FORMAT_TO_STRFTIME.items():
|
||||||
assert result == '%d/%m/%Y %H:%M:%S'
|
assert _frontend_date_format_to_strftime(frontend_fmt) == strftime_fmt
|
||||||
|
|
||||||
def test_yyyy_mm_dd(self):
|
def test_invalid_format_raises(self):
|
||||||
"""Converts yyyy-MM-dd to strftime."""
|
"""Invalid format raises ValueError with allowed list in message."""
|
||||||
result = _frontend_date_format_to_strftime('yyyy-MM-dd')
|
with raises(ValueError, match='Invalid date_format'):
|
||||||
assert result == '%Y-%m-%d'
|
_frontend_date_format_to_strftime('yyyy-MM-dd')
|
||||||
|
with raises(ValueError, match='Allowed formats'):
|
||||||
|
_frontend_date_format_to_strftime('invalid')
|
||||||
|
|
||||||
def test_iso_datetime(self):
|
|
||||||
"""Converts yyyy-MM-dd HH:mm:ss to strftime."""
|
class TestValidateFrontendDateFormat:
|
||||||
result = _frontend_date_format_to_strftime('yyyy-MM-dd HH:mm:ss')
|
"""Tests for validate_frontend_date_format."""
|
||||||
assert result == '%Y-%m-%d %H:%M:%S'
|
|
||||||
|
def test_none_or_empty_does_not_raise(self):
|
||||||
|
"""None or empty string does not raise."""
|
||||||
|
validate_frontend_date_format(None)
|
||||||
|
validate_frontend_date_format('')
|
||||||
|
validate_frontend_date_format(' ')
|
||||||
|
|
||||||
|
def test_allowed_formats_do_not_raise(self):
|
||||||
|
"""All allowed formats pass validation."""
|
||||||
|
for fmt in ALLOWED_FRONTEND_DATE_FORMATS:
|
||||||
|
validate_frontend_date_format(fmt)
|
||||||
|
|
||||||
|
def test_invalid_format_raises(self):
|
||||||
|
"""Invalid format raises ValueError."""
|
||||||
|
with raises(ValueError, match='Invalid date_format'):
|
||||||
|
validate_frontend_date_format('yyyy-MM-dd')
|
||||||
|
with raises(ValueError, match='Invalid date_format'):
|
||||||
|
validate_frontend_date_format('custom-bad-format')
|
||||||
|
|
||||||
|
|
||||||
class _IterableWithContains:
|
class _IterableWithContains:
|
||||||
|
|||||||
@@ -555,6 +555,27 @@ def test_validate_business_rules_valid_start_and_end_date(valid_train_params_dic
|
|||||||
params.validate_business_rules() # Should not raise
|
params.validate_business_rules() # Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_business_rules_invalid_date_format(valid_train_params_dict):
|
||||||
|
"""Test validate_business_rules raises when date_format is not allowed."""
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
|
||||||
|
params = TrainModelParams.from_dict(valid_train_params_dict)
|
||||||
|
params.date_format = 'yyyy-MM-dd'
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Invalid date_format'):
|
||||||
|
params.validate_business_rules()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_business_rules_valid_date_format(valid_train_params_dict):
|
||||||
|
"""Test validate_business_rules accepts allowed date_format."""
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
|
||||||
|
params = TrainModelParams.from_dict(valid_train_params_dict)
|
||||||
|
params.date_format = 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
|
||||||
|
params.validate_business_rules() # Should not raise
|
||||||
|
|
||||||
|
|
||||||
def test_validate_business_rules_polynomial_regression_valid(valid_train_params_dict):
|
def test_validate_business_rules_polynomial_regression_valid(valid_train_params_dict):
|
||||||
"""Test validate_business_rules accepts valid Polynomial Regression config."""
|
"""Test validate_business_rules accepts valid Polynomial Regression config."""
|
||||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
|||||||
@@ -1125,27 +1125,23 @@ class TestExtractModelEquationPolynomial:
|
|||||||
|
|
||||||
|
|
||||||
class TestFrontendFormatToStrftime:
|
class TestFrontendFormatToStrftime:
|
||||||
"""Tests for _frontend_format_to_strftime."""
|
"""Tests for _frontend_format_to_strftime (alias from models)."""
|
||||||
|
|
||||||
def test_empty_or_none_returns_none(self):
|
def test_empty_or_none_returns_none(self):
|
||||||
"""Empty string or None returns None (same behavior as models module)."""
|
"""Empty string or None returns None (same behavior as models module)."""
|
||||||
assert _frontend_format_to_strftime('') is None
|
assert _frontend_format_to_strftime('') is None
|
||||||
assert _frontend_format_to_strftime(None) is None
|
assert _frontend_format_to_strftime(None) is None
|
||||||
|
|
||||||
def test_dd_mm_yyyy_hh_mm_ss(self):
|
def test_allowed_formats_convert_correctly(self):
|
||||||
"""Converts dd/MM/yyyy HH:mm:ss to strftime."""
|
"""Allowed frontend formats convert to strftime via mapper."""
|
||||||
result = _frontend_format_to_strftime('dd/MM/yyyy HH:mm:ss')
|
assert _frontend_format_to_strftime('dd/MM/yyyy HH:mm:ss') == '%d/%m/%Y %H:%M:%S'
|
||||||
assert result == '%d/%m/%Y %H:%M:%S'
|
assert _frontend_format_to_strftime('yyyy-MM-dd HH:mm:ss') == '%Y-%m-%d %H:%M:%S'
|
||||||
|
assert _frontend_format_to_strftime('MM/dd/yyyy HH:mm:ss') == '%m/%d/%Y %H:%M:%S'
|
||||||
|
|
||||||
def test_yyyy_mm_dd(self):
|
def test_invalid_format_raises(self):
|
||||||
"""Converts yyyy-MM-dd to strftime."""
|
"""Invalid date format raises ValueError."""
|
||||||
result = _frontend_format_to_strftime('yyyy-MM-dd')
|
with pytest.raises(ValueError, match='Invalid date_format'):
|
||||||
assert result == '%Y-%m-%d'
|
_frontend_format_to_strftime('yyyy-MM-dd')
|
||||||
|
|
||||||
def test_iso_like_datetime(self):
|
|
||||||
"""Converts yyyy-MM-dd HH:mm:ss to strftime."""
|
|
||||||
result = _frontend_format_to_strftime('yyyy-MM-dd HH:mm:ss')
|
|
||||||
assert result == '%Y-%m-%d %H:%M:%S'
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnsureDateColumnParsed:
|
class TestEnsureDateColumnParsed:
|
||||||
@@ -1215,8 +1211,11 @@ class TestEnsureDateColumnParsed:
|
|||||||
|
|
||||||
def test_invalid_values_coerced_to_nat(self, date_params):
|
def test_invalid_values_coerced_to_nat(self, date_params):
|
||||||
"""Invalid date strings are coerced to NaT when format is set."""
|
"""Invalid date strings are coerced to NaT when format is set."""
|
||||||
date_params.date_format = 'yyyy-MM-dd'
|
date_params.date_format = 'yyyy-MM-dd HH:mm:ss'
|
||||||
data = pd.DataFrame({'ts': ['2023-01-01', 'not-a-date', '2023-12-31'], 'x': [1, 2, 3]})
|
data = pd.DataFrame({
|
||||||
|
'ts': ['2023-01-01 00:00:00', 'not-a-date', '2023-12-31 00:00:00'],
|
||||||
|
'x': [1, 2, 3],
|
||||||
|
})
|
||||||
result = _ensure_date_column_parsed(data, date_params)
|
result = _ensure_date_column_parsed(data, date_params)
|
||||||
assert pd.isna(result['ts'].iloc[1])
|
assert pd.isna(result['ts'].iloc[1])
|
||||||
assert result['ts'].iloc[0].year == 2023
|
assert result['ts'].iloc[0].year == 2023
|
||||||
|
|||||||
Reference in New Issue
Block a user