Code import - branch release/SIENTIAPDE-1645
This commit is contained in:
0
tests/utils/__init__.py
Normal file
0
tests/utils/__init__.py
Normal file
0
tests/utils/models/__init__.py
Normal file
0
tests/utils/models/__init__.py
Normal file
75
tests/utils/models/test_experiment_status.py
Normal file
75
tests/utils/models/test_experiment_status.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for ExperimentStatus enum."""
|
||||
|
||||
from model_manager.utils.models.experiment_status import ExperimentStatus
|
||||
|
||||
|
||||
def test_experiment_status_values():
|
||||
"""Test that all expected status values exist."""
|
||||
assert ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR == 'ORCHESTRATOR_VALIDATION_ERROR'
|
||||
assert ExperimentStatus.ORCHESTRATOR_WAITING_PROC == 'ORCHESTRATOR_WAITING_PROC'
|
||||
assert ExperimentStatus.TRAINING_SUCCESS == 'TRAINING_SUCCESS'
|
||||
assert ExperimentStatus.TRAINING_ERROR == 'TRAINING_ERROR'
|
||||
|
||||
|
||||
def test_experiment_status_count():
|
||||
"""Test that enum has exactly 4 status values."""
|
||||
assert len(ExperimentStatus) == 4
|
||||
|
||||
|
||||
def test_experiment_status_is_string():
|
||||
"""Test that enum values are strings."""
|
||||
for status in ExperimentStatus:
|
||||
assert isinstance(status.value, str)
|
||||
assert isinstance(status, str)
|
||||
|
||||
|
||||
def test_experiment_status_membership():
|
||||
"""Test membership checks for status values."""
|
||||
assert 'ORCHESTRATOR_VALIDATION_ERROR' in [s.value for s in ExperimentStatus]
|
||||
assert 'ORCHESTRATOR_WAITING_PROC' in [s.value for s in ExperimentStatus]
|
||||
assert 'TRAINING_SUCCESS' in [s.value for s in ExperimentStatus]
|
||||
assert 'TRAINING_ERROR' in [s.value for s in ExperimentStatus]
|
||||
|
||||
|
||||
def test_experiment_status_iteration():
|
||||
"""Test that enum can be iterated."""
|
||||
statuses = list(ExperimentStatus)
|
||||
assert len(statuses) == 4
|
||||
assert ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR in statuses
|
||||
assert ExperimentStatus.ORCHESTRATOR_WAITING_PROC in statuses
|
||||
assert ExperimentStatus.TRAINING_SUCCESS in statuses
|
||||
assert ExperimentStatus.TRAINING_ERROR in statuses
|
||||
|
||||
|
||||
def test_experiment_status_comparison():
|
||||
"""Test that enum values can be compared with strings."""
|
||||
assert ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR == 'ORCHESTRATOR_VALIDATION_ERROR'
|
||||
assert ExperimentStatus.ORCHESTRATOR_WAITING_PROC == 'ORCHESTRATOR_WAITING_PROC'
|
||||
assert ExperimentStatus.TRAINING_SUCCESS == 'TRAINING_SUCCESS'
|
||||
assert str(ExperimentStatus.TRAINING_ERROR) != 'TRAINING_SUCCESS'
|
||||
|
||||
|
||||
def test_experiment_status_access_by_name():
|
||||
"""Test accessing enum members by name."""
|
||||
assert (
|
||||
ExperimentStatus['ORCHESTRATOR_VALIDATION_ERROR']
|
||||
== ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR
|
||||
)
|
||||
assert (
|
||||
ExperimentStatus['ORCHESTRATOR_WAITING_PROC'] == ExperimentStatus.ORCHESTRATOR_WAITING_PROC
|
||||
)
|
||||
assert ExperimentStatus['TRAINING_SUCCESS'] == ExperimentStatus.TRAINING_SUCCESS
|
||||
assert ExperimentStatus['TRAINING_ERROR'] == ExperimentStatus.TRAINING_ERROR
|
||||
|
||||
|
||||
def test_experiment_status_access_by_value():
|
||||
"""Test accessing enum members by value."""
|
||||
assert (
|
||||
ExperimentStatus('ORCHESTRATOR_VALIDATION_ERROR')
|
||||
== ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR
|
||||
)
|
||||
assert (
|
||||
ExperimentStatus('ORCHESTRATOR_WAITING_PROC') == ExperimentStatus.ORCHESTRATOR_WAITING_PROC
|
||||
)
|
||||
assert ExperimentStatus('TRAINING_SUCCESS') == ExperimentStatus.TRAINING_SUCCESS
|
||||
assert ExperimentStatus('TRAINING_ERROR') == ExperimentStatus.TRAINING_ERROR
|
||||
51
tests/utils/models/test_init.py
Normal file
51
tests/utils/models/test_init.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Unit tests for models __init__.py module."""
|
||||
|
||||
from model_manager.utils.models import (
|
||||
ExperimentStatus,
|
||||
TrainModelParams,
|
||||
TrainModelResult,
|
||||
)
|
||||
|
||||
|
||||
def test_experiment_status_import():
|
||||
"""Test that ExperimentStatus can be imported from models package."""
|
||||
assert ExperimentStatus is not None
|
||||
assert hasattr(ExperimentStatus, 'ORCHESTRATOR_WAITING_PROC')
|
||||
assert hasattr(ExperimentStatus, 'TRAINING_SUCCESS')
|
||||
|
||||
|
||||
def test_train_model_params_import():
|
||||
"""Test that TrainModelParams can be imported from models package."""
|
||||
assert TrainModelParams is not None
|
||||
assert callable(TrainModelParams)
|
||||
|
||||
|
||||
def test_train_model_result_import():
|
||||
"""Test that TrainModelResult can be imported from models package."""
|
||||
assert TrainModelResult is not None
|
||||
# Dataclasses have __dataclass_fields__
|
||||
assert hasattr(TrainModelResult, '__dataclass_fields__')
|
||||
|
||||
|
||||
def test_all_exports():
|
||||
"""Test that __all__ contains all expected exports."""
|
||||
from model_manager.utils.models import __all__
|
||||
|
||||
assert 'ExperimentStatus' in __all__
|
||||
assert 'TrainModelParams' in __all__
|
||||
assert 'TrainModelResult' in __all__
|
||||
assert len(__all__) == 3
|
||||
|
||||
|
||||
def test_no_extra_exports():
|
||||
"""Test that only expected items are exported."""
|
||||
import model_manager.utils.models as models_module
|
||||
|
||||
# Get all public attributes (not starting with _)
|
||||
public_attrs = [attr for attr in dir(models_module) if not attr.startswith('_')]
|
||||
|
||||
# Should only have the 3 main classes
|
||||
expected_public = {'ExperimentStatus', 'TrainModelParams', 'TrainModelResult'}
|
||||
|
||||
# Check that our expected classes are present
|
||||
assert expected_public.issubset(set(public_attrs))
|
||||
321
tests/utils/models/test_train_model_params.py
Normal file
321
tests/utils/models/test_train_model_params.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""Unit tests for TrainModelParams (current schema)."""
|
||||
|
||||
import copy
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from model_manager.utils.models.train_model_params import (
|
||||
DEFAULT_TRAIN_DATE_FORMAT,
|
||||
TrainModelParams,
|
||||
validate_frontend_date_format,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_model_metadata() -> dict:
|
||||
"""Minimal truthy metadata so validate_business_rules passes schema lookup."""
|
||||
return {'schemas': {'components': {'schemas': {}}}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_train_params_dict(minimal_model_metadata) -> dict:
|
||||
"""Valid dictionary for TrainModelParams.from_dict."""
|
||||
return {
|
||||
'variable_columns': ['var1', 'var2'],
|
||||
'target_variable': 'target',
|
||||
'bucket_name': 'test-bucket',
|
||||
'file_name': 'test-file.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
'experiment_run_id': 1,
|
||||
'model_name': 'Linear Regression',
|
||||
'val_file_name': None,
|
||||
'data_model_kwargs': {},
|
||||
'model_kwargs': {},
|
||||
'opt_params': {},
|
||||
'model_type': 'linear_regression',
|
||||
'model_id': None,
|
||||
'model_metadata': minimal_model_metadata,
|
||||
}
|
||||
|
||||
|
||||
def test_from_dict_success(valid_train_params_dict):
|
||||
"""from_dict builds params and experiment_name from model_name."""
|
||||
params = TrainModelParams.from_dict(valid_train_params_dict)
|
||||
|
||||
assert params.variable_columns == ['var1', 'var2']
|
||||
assert params.target_variable == 'target'
|
||||
assert params.bucket_name == 'test-bucket'
|
||||
assert params.experiment_run_id == 1
|
||||
assert params.experiment_name == 'Linear Regression'
|
||||
assert params.model_metadata is valid_train_params_dict['model_metadata']
|
||||
|
||||
|
||||
def test_from_dict_date_format_omitted_uses_default(valid_train_params_dict):
|
||||
"""Missing date_format defaults to DEFAULT_TRAIN_DATE_FORMAT."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['date_format']
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_format == DEFAULT_TRAIN_DATE_FORMAT
|
||||
|
||||
|
||||
def test_from_dict_date_format_blank_uses_default(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_format'] = ' '
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_format == DEFAULT_TRAIN_DATE_FORMAT
|
||||
|
||||
|
||||
def test_from_dict_superfluous_date_column_camel_key_is_ignored(valid_train_params_dict):
|
||||
"""Only snake_case keys are read; dateColumn does not populate date_column."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['dateColumn'] = 'wrong_name'
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_column == 'timestamp'
|
||||
|
||||
|
||||
def test_from_dict_missing_date_column_raises(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['date_column']
|
||||
with pytest.raises(ValueError, match='date_column is required'):
|
||||
TrainModelParams.from_dict(d)
|
||||
|
||||
|
||||
def test_from_dict_date_format_non_string_raises(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_format'] = 12345
|
||||
with pytest.raises(TypeError, match='date_format must be a string'):
|
||||
TrainModelParams.from_dict(d)
|
||||
|
||||
|
||||
def test_from_dict_coerces_experiment_run_id_string(valid_train_params_dict):
|
||||
"""Numeric string experiment_run_id is coerced to int."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['experiment_run_id'] = '42'
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.experiment_run_id == 42
|
||||
|
||||
|
||||
def test_from_dict_model_metadata_none(valid_train_params_dict):
|
||||
"""model_metadata may be None before load_model_metadata activity."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = None
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.model_metadata is None
|
||||
|
||||
|
||||
def test_coerce_experiment_run_id_rejects_bool():
|
||||
"""Boolean must not be accepted as experiment_run_id."""
|
||||
with pytest.raises(TypeError, match='experiment_run_id must be an integer'):
|
||||
TrainModelParams._coerce_experiment_run_id(True)
|
||||
|
||||
|
||||
def test_parse_optional_model_metadata_rejects_list():
|
||||
"""model_metadata must be dict or None."""
|
||||
with pytest.raises(TypeError, match='model_metadata must be a dict or None'):
|
||||
TrainModelParams._parse_optional_model_metadata([])
|
||||
|
||||
|
||||
def test_check_none_raises_value_error():
|
||||
with pytest.raises(ValueError, match='test_field is required'):
|
||||
TrainModelParams._check_none(None, str, 'test_field')
|
||||
|
||||
|
||||
def test_check_none_raises_type_error():
|
||||
with pytest.raises(TypeError, match='test_field must be of type str'):
|
||||
TrainModelParams._check_none(123, str, 'test_field')
|
||||
|
||||
|
||||
def test_validate_business_rules_success(valid_train_params_dict):
|
||||
params = TrainModelParams.from_dict(valid_train_params_dict)
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_missing_model_metadata(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = None
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='model_metadata is required'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_train_size_out_of_range(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['train_size'] = 5
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='train_size must be between'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_empty_variable_columns(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['variable_columns'] = []
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='variable_columns cannot be empty'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_empty_target(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['target_variable'] = ' '
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='target_variable cannot be empty'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_whitespace_date_column(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_column'] = ' '
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='date_column cannot be empty'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_from_dict_missing_required_key(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['bucket_name']
|
||||
with pytest.raises(ValueError, match='bucket_name is required'):
|
||||
TrainModelParams.from_dict(d)
|
||||
|
||||
|
||||
def test_to_dict_roundtrip_keys(valid_train_params_dict):
|
||||
params = TrainModelParams.from_dict(valid_train_params_dict)
|
||||
d = params.to_dict()
|
||||
assert 'variable_columns' in d
|
||||
assert d['experiment_run_id'] == 1
|
||||
|
||||
|
||||
def test_coerce_experiment_run_id_float():
|
||||
assert TrainModelParams._coerce_experiment_run_id(2.0) == 2
|
||||
|
||||
|
||||
def test_coerce_experiment_run_id_none_raises():
|
||||
with pytest.raises(ValueError, match='experiment_run_id is required'):
|
||||
TrainModelParams._coerce_experiment_run_id(None)
|
||||
|
||||
|
||||
def test_coerce_experiment_run_id_invalid_type():
|
||||
with pytest.raises(TypeError, match='integer or numeric string'):
|
||||
TrainModelParams._coerce_experiment_run_id([1])
|
||||
|
||||
|
||||
def test_validate_model_param_schema_validation_error(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {
|
||||
'schemas': {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'data_model': {
|
||||
'type': 'object',
|
||||
'properties': {'x': {'type': 'integer'}},
|
||||
'required': ['x'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
p.data_model_kwargs = {}
|
||||
with pytest.raises(ValueError, match='Model parameters validation failed'):
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_model_param_unexpected_validator_error(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {
|
||||
'schemas': {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'data_model': {'type': 'object'},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
with patch('model_manager.utils.models.train_model_params.Draft202012Validator') as m:
|
||||
m.return_value.validate.side_effect = RuntimeError('boom')
|
||||
with pytest.raises(RuntimeError, match='boom'):
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_date_format_invalid(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_format'] = 'not-an-allowed-format'
|
||||
p = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='Invalid date_format'):
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_required_strings_whitespace_bucket_file_model(valid_train_params_dict):
|
||||
for field, msg in [
|
||||
('bucket_name', 'bucket_name cannot be empty'),
|
||||
('file_name', 'file_name cannot be empty'),
|
||||
('model_name', 'model_name cannot be empty'),
|
||||
]:
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d[field] = ' '
|
||||
p = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match=msg):
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_model_param_only_data_model_schema(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {
|
||||
'schemas': {'components': {'schemas': {'data_model': {'type': 'object'}}}}
|
||||
}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
p.data_model_kwargs = {}
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_model_param_only_model_schema(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {'schemas': {'components': {'schemas': {'model': {'type': 'object'}}}}}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
p.model_kwargs = {}
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_model_param_only_opt_params_schema(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {
|
||||
'schemas': {'components': {'schemas': {'opt_params': {'type': 'object'}}}}
|
||||
}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
p.opt_params = {}
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_model_param_all_schema_branches(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['model_metadata'] = {
|
||||
'schemas': {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'data_model': {'type': 'object'},
|
||||
'model': {'type': 'object'},
|
||||
'opt_params': {'type': 'object'},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p = TrainModelParams.from_dict(d)
|
||||
p.data_model_kwargs = {}
|
||||
p.model_kwargs = {}
|
||||
p.opt_params = {}
|
||||
p.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_frontend_date_format_whitespace_returns():
|
||||
validate_frontend_date_format(' ')
|
||||
|
||||
|
||||
def test_validate_frontend_date_format_valid_returns():
|
||||
validate_frontend_date_format('dd/MM/yyyy HH:mm:ss')
|
||||
71
tests/utils/models/test_train_model_result.py
Normal file
71
tests/utils/models/test_train_model_result.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for TrainModelResult dataclass."""
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_params() -> TrainModelParams:
|
||||
"""Minimal TrainModelParams for TrainModelResult tests."""
|
||||
return TrainModelParams.from_dict(
|
||||
{
|
||||
'variable_columns': ['a'],
|
||||
'target_variable': 't',
|
||||
'bucket_name': 'b',
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': '\n',
|
||||
'decimal_separator': '.',
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
'experiment_run_id': 1,
|
||||
'model_name': 'Linear Regression',
|
||||
'val_file_name': None,
|
||||
'data_model_kwargs': {},
|
||||
'model_kwargs': {},
|
||||
'opt_params': {},
|
||||
'model_type': 'linear_regression',
|
||||
'model_id': None,
|
||||
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_frames():
|
||||
train = pd.DataFrame({'a': [1, 2], 't': [1.0, 2.0]})
|
||||
val = pd.DataFrame({'a': [3], 't': [3.0]})
|
||||
return train, val
|
||||
|
||||
|
||||
def test_train_model_result_creation(sample_params, sample_frames):
|
||||
train, val = sample_frames
|
||||
result = TrainModelResult(params=sample_params, train_data=train, val_data=val)
|
||||
assert result.params is sample_params
|
||||
assert result.train_data.equals(train)
|
||||
assert result.val_data.equals(val)
|
||||
assert result.run_name is None
|
||||
|
||||
|
||||
def test_train_model_result_optional_paths(sample_params, sample_frames):
|
||||
train, val = sample_frames
|
||||
result = TrainModelResult(
|
||||
params=sample_params,
|
||||
train_data=train,
|
||||
val_data=val,
|
||||
run_name='run-1',
|
||||
run_id='rid',
|
||||
run_dir='/tmp/x',
|
||||
mse_val=0.1,
|
||||
mae_val=0.2,
|
||||
r2_val=0.99,
|
||||
)
|
||||
assert result.run_name == 'run-1'
|
||||
assert result.run_id == 'rid'
|
||||
assert result.run_dir == '/tmp/x'
|
||||
assert result.mse_val == 0.1
|
||||
630
tests/utils/repository/test_data_manager_repository.py
Normal file
630
tests/utils/repository/test_data_manager_repository.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Unit tests for DataManagerRepository and module helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from model_manager.runtime_paths import REPORTS_ROOT
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
from model_manager.utils.repository import data_manager_repository as dmr
|
||||
|
||||
|
||||
def test_train_test_split_dataframe_shuffle():
|
||||
df = pd.DataFrame({'a': range(10)})
|
||||
tr, te = dmr.train_test_split(df, train_size=0.7, random_state=0, shuffle=True)
|
||||
assert len(tr) == 7 and len(te) == 3
|
||||
|
||||
|
||||
def test_train_test_split_dataframe_no_shuffle():
|
||||
df = pd.DataFrame({'a': range(10)})
|
||||
tr, te = dmr.train_test_split(df, train_size=0.5, shuffle=False)
|
||||
assert list(tr['a']) == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_train_test_split_dataframe_returns_dataframes():
|
||||
df = pd.DataFrame(np.arange(20).reshape(10, 2), columns=['a', 'b'])
|
||||
tr, te = dmr.train_test_split(df, train_size=0.5, shuffle=False, random_state=None)
|
||||
assert isinstance(tr, pd.DataFrame)
|
||||
assert isinstance(te, pd.DataFrame)
|
||||
assert tr.shape[0] == 5 and te.shape[0] == 5
|
||||
|
||||
|
||||
def _params(**kwargs) -> TrainModelParams:
|
||||
base: dict[str, Any] = {
|
||||
'variable_columns': ['v1'],
|
||||
'target_variable': 't',
|
||||
'bucket_name': 'b',
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
'experiment_run_id': 1,
|
||||
'model_name': 'Linear Regression',
|
||||
'val_file_name': None,
|
||||
'data_model_kwargs': {},
|
||||
'model_kwargs': {},
|
||||
'opt_params': {},
|
||||
'model_type': 'linear_regression',
|
||||
'model_id': None,
|
||||
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||
}
|
||||
base.update(kwargs)
|
||||
return TrainModelParams.from_dict(base)
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_missing_column_raises():
|
||||
df = pd.DataFrame({'a': [1]})
|
||||
p = _params(date_column='missing')
|
||||
with pytest.raises(ValueError, match='not found in dataset columns'):
|
||||
dmr._ensure_date_column_parsed(df, p)
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_success():
|
||||
df = pd.DataFrame({'a': range(3), 'ts': ['2024-01-01 10:00:00'] * 3})
|
||||
p = _params(date_column='ts')
|
||||
out = dmr._ensure_date_column_parsed(df, p)
|
||||
assert pd.api.types.is_datetime64_any_dtype(out['ts'])
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_naive_with_frontend_format():
|
||||
"""CSV timestamps without timezone use params.date_format strftime mapping."""
|
||||
df = pd.DataFrame({'ts': ['2025-06-02 00:00:00', '2025-06-02 01:00:00']})
|
||||
p = _params(date_column='ts', date_format='yyyy-MM-dd HH:mm:ss')
|
||||
out = dmr._ensure_date_column_parsed(df, p)
|
||||
assert pd.api.types.is_datetime64_any_dtype(out['ts'])
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_invalid_raises():
|
||||
df = pd.DataFrame({'a': range(3), 'ts': ['not-a-date'] * 3})
|
||||
p = _params(date_column='ts')
|
||||
with pytest.raises(ValueError, match='Failed to parse date column'):
|
||||
dmr._ensure_date_column_parsed(df, p)
|
||||
|
||||
|
||||
def test_prepare_training_data_csv_load_failure():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = _params()
|
||||
with patch(
|
||||
'model_manager.utils.repository.data_manager_repository.pd.read_csv',
|
||||
side_effect=pd.errors.ParserError('bad'),
|
||||
):
|
||||
with pytest.raises(ValueError, match='Failed to load training CSV'):
|
||||
repo.prepare_training_data(b'x', None, p, {})
|
||||
|
||||
|
||||
def test_prepare_training_data_empty_after_load():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
# Headers only; timestamp column present but no data rows
|
||||
csv_bytes = b'timestamp,v1,t\n'
|
||||
with pytest.raises(ValueError, match='Training data view is empty after transformation'):
|
||||
repo.prepare_training_data(csv_bytes, None, p, {})
|
||||
|
||||
|
||||
def test_prepare_training_data_empty_after_transformation(monkeypatch):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
monkeypatch.setattr(
|
||||
repo,
|
||||
'_configure_datetime_index',
|
||||
lambda *_args, **_kwargs: pd.DataFrame(columns=['v1', 't']),
|
||||
)
|
||||
monkeypatch.setattr(repo, '_set_timezone_on_index', lambda data, *_args, **_kwargs: data)
|
||||
with pytest.raises(ValueError, match='Training data view is empty after transformation'):
|
||||
repo.prepare_training_data(b'timestamp,v1,t\n', None, p, {})
|
||||
|
||||
|
||||
def _minimal_dict_for_prepare():
|
||||
return {
|
||||
'variable_columns': ['v1'],
|
||||
'target_variable': 't',
|
||||
'bucket_name': 'b',
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
'experiment_run_id': 1,
|
||||
'model_name': 'Linear Regression',
|
||||
'val_file_name': None,
|
||||
'data_model_kwargs': {},
|
||||
'model_kwargs': {},
|
||||
'opt_params': {},
|
||||
'model_type': 'linear_regression',
|
||||
'model_id': None,
|
||||
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||
}
|
||||
|
||||
|
||||
def _csv_bytes_with_ts(n_rows: int = 20) -> bytes:
|
||||
"""CSV with leading timestamp column (naive, matches default date_format)."""
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(n_rows):
|
||||
lines.append(f'2024-01-{i + 1:02d} 00:00:00,{i},{i + 1}')
|
||||
return '\n'.join(lines).encode()
|
||||
|
||||
|
||||
def test_prepare_training_data_validation_csv_invalid():
|
||||
from io import BytesIO
|
||||
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
train_csv = _csv_bytes_with_ts(5)
|
||||
train_df = pd.read_csv(BytesIO(train_csv), sep=',', decimal='.')
|
||||
with patch.object(
|
||||
dmr.pd,
|
||||
'read_csv',
|
||||
side_effect=[train_df, pd.errors.ParserError('bad val')],
|
||||
):
|
||||
with pytest.raises(ValueError, match='Failed to load validation CSV'):
|
||||
repo.prepare_training_data(train_csv, b'broken', p, {})
|
||||
|
||||
|
||||
def test_prepare_training_data_validation_empty_val():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
train_csv = _csv_bytes_with_ts(5)
|
||||
val_csv = b'timestamp,v1,t\n'
|
||||
with pytest.raises(ValueError, match='Validation data view is empty'):
|
||||
repo.prepare_training_data(train_csv, val_csv, p, {})
|
||||
|
||||
|
||||
def test_prepare_training_data_drops_row_with_blank_timestamp():
|
||||
"""Rows with empty date_column values are removed before datetime parsing."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
d = _minimal_dict_for_prepare()
|
||||
d['date_column'] = 'timestamp'
|
||||
d['date_format'] = 'yyyy-MM-dd HH:mm:ss'
|
||||
p = TrainModelParams.from_dict(d)
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(10):
|
||||
if i == 3:
|
||||
lines.append(',1.0,2.0')
|
||||
else:
|
||||
lines.append(f'2025-06-01 {i:02d}:00:00,1.0,2.0')
|
||||
csv = '\n'.join(lines).encode()
|
||||
res = repo.prepare_training_data(csv, None, p, {})
|
||||
assert len(res.train_data) + len(res.val_data) == 9
|
||||
|
||||
|
||||
def test_prepare_training_data_split_path():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
train_csv = _csv_bytes_with_ts(20)
|
||||
res = repo.prepare_training_data(train_csv, None, p, {})
|
||||
assert res.train_data is not None and res.val_data is not None
|
||||
|
||||
|
||||
def test_prepare_training_data_explicit_validation_success():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
train_csv = _csv_bytes_with_ts(10)
|
||||
val_csv = _csv_bytes_with_ts(5)
|
||||
res = repo.prepare_training_data(train_csv, val_csv, p, {})
|
||||
assert len(res.val_data) == 5
|
||||
|
||||
|
||||
def test_coerce_non_timestamp_columns_to_numeric_success():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
idx = pd.date_range('2024-01-01', periods=2, freq='h', tz='UTC')
|
||||
df = pd.DataFrame(
|
||||
{'v1': ['1.25', '2.75'], 't': ['10', '11']},
|
||||
index=idx,
|
||||
)
|
||||
out = repo._coerce_non_timestamp_columns_to_numeric(df, p, {})
|
||||
assert pd.api.types.is_numeric_dtype(out['v1'])
|
||||
assert pd.api.types.is_numeric_dtype(out['t'])
|
||||
assert float(out['v1'].iloc[0]) == 1.25
|
||||
assert float(out['t'].iloc[1]) == 11.0
|
||||
|
||||
|
||||
def test_coerce_non_timestamp_columns_to_numeric_invalid_values_to_nan():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
idx = pd.date_range('2024-01-01', periods=2, freq='h', tz='UTC')
|
||||
df = pd.DataFrame(
|
||||
{'v1': ['1.25', 'oops'], 't': ['10', 'bad']},
|
||||
index=idx,
|
||||
)
|
||||
out = repo._coerce_non_timestamp_columns_to_numeric(df, p, {})
|
||||
assert np.isnan(out['v1'].iloc[1])
|
||||
assert np.isnan(out['t'].iloc[1])
|
||||
|
||||
|
||||
def test_prepare_training_data_coerces_non_timestamp_columns_to_numeric():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(10):
|
||||
v1 = 'bad' if i == 4 else f'{i + 0.5}'
|
||||
t = 'bad' if i == 7 else f'{i + 1.0}'
|
||||
lines.append(f'2025-06-01 {i:02d}:00:00,{v1},{t}')
|
||||
csv = '\n'.join(lines).encode()
|
||||
res = repo.prepare_training_data(csv, None, p, {})
|
||||
joined = pd.concat([res.train_data, res.val_data], axis=0).sort_index()
|
||||
assert pd.api.types.is_numeric_dtype(joined['v1'])
|
||||
assert pd.api.types.is_numeric_dtype(joined['t'])
|
||||
assert joined['v1'].isna().sum() == 1
|
||||
assert joined['t'].isna().sum() == 1
|
||||
|
||||
|
||||
def test_as_series_series():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
s = pd.Series([1.0, 2.0])
|
||||
assert repo._as_series(s).equals(s)
|
||||
|
||||
|
||||
def test_as_series_one_column_df():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
df = pd.DataFrame({'x': [1.0, 2.0]})
|
||||
out = repo._as_series(df)
|
||||
assert isinstance(out, pd.Series)
|
||||
|
||||
|
||||
def test_as_series_multi_column_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
df = pd.DataFrame({'a': [1.0], 'b': [2.0]})
|
||||
with pytest.raises(ValueError, match='single-column'):
|
||||
repo._as_series(df)
|
||||
|
||||
|
||||
def test_compute_regression_metrics_requires_y_pred():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0]}),
|
||||
val_data=pd.DataFrame({'t': [1.0]}),
|
||||
y_pred=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match='y_pred must be set'):
|
||||
repo.compute_regression_metrics(tmr, MagicMock())
|
||||
|
||||
|
||||
def test_compute_regression_metrics_no_overlap():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0]}),
|
||||
val_data=pd.DataFrame({'t': [1.0]}, index=[10]),
|
||||
y_pred=pd.DataFrame({'p': [1.0]}, index=[20]),
|
||||
)
|
||||
with pytest.raises(ValueError, match='No overlapping indices'):
|
||||
repo.compute_regression_metrics(tmr, MagicMock())
|
||||
|
||||
|
||||
def test_compute_regression_metrics_linear_equation():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_type = 'linear_regression'
|
||||
idx = pd.Index([0, 1])
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
val_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
y_pred=pd.DataFrame({'p': [1.0, 2.0]}, index=idx),
|
||||
)
|
||||
regr = MagicMock()
|
||||
regr.coef_ = np.array([0.5])
|
||||
regr.intercept_ = 1.0
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = MagicMock()
|
||||
wrapper.model.regr = regr
|
||||
out = repo.compute_regression_metrics(tmr, wrapper)
|
||||
assert out.mse_val is not None and out.equation is not None
|
||||
|
||||
|
||||
def test_compute_regression_metrics_linear_skips_equation_without_sklearn_regr():
|
||||
"""E2E dummy wrappers expose model without sklearn .regr; metrics still compute."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_type = 'linear_regression'
|
||||
idx = pd.Index([0, 1])
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
val_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
y_pred=pd.DataFrame({'p': [1.0, 2.0]}, index=idx),
|
||||
)
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = object()
|
||||
out = repo.compute_regression_metrics(tmr, wrapper)
|
||||
assert out.mse_val is not None and out.equation is None
|
||||
|
||||
|
||||
def test_compute_regression_metrics_non_linear_skips_equation():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_type = 'xgboost'
|
||||
idx = pd.Index([0, 1])
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
val_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
y_pred=pd.DataFrame({'p': [1.0, 2.0]}, index=idx),
|
||||
)
|
||||
out = repo.compute_regression_metrics(tmr, MagicMock())
|
||||
assert out.mse_val is not None and out.equation is None
|
||||
|
||||
|
||||
def test_configure_datetime_index_none_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
with pytest.raises(ValueError, match='Data is None'):
|
||||
repo._configure_datetime_index(None, p, {})
|
||||
|
||||
|
||||
def test_configure_datetime_index_already_datetime_index():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
existing_idx = pd.date_range('2024-01-01', periods=3, freq='h')
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'timestamp': pd.to_datetime(
|
||||
['2024-01-03 00:00:00', '2024-01-01 00:00:00', '2024-01-02 00:00:00']
|
||||
),
|
||||
'v1': [1, 2, 3],
|
||||
't': [1, 2, 3],
|
||||
},
|
||||
index=existing_idx,
|
||||
)
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
assert out.index.equals(pd.DatetimeIndex(pd.to_datetime(sorted(df['timestamp'].tolist()))))
|
||||
|
||||
|
||||
def test_configure_datetime_index_from_date_column():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict({**_minimal_dict_for_prepare(), 'date_column': 'mydate'})
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'mydate': pd.date_range('2024-01-01', periods=3, freq='D'),
|
||||
'v1': [1, 2, 3],
|
||||
't': [1, 2, 3],
|
||||
}
|
||||
)
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
def test_configure_datetime_index_missing_date_column_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict({**_minimal_dict_for_prepare(), 'date_column': 'mydate'})
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'timestamp': pd.date_range('2024-01-01', periods=3, freq='D'),
|
||||
'v1': [1, 2, 3],
|
||||
't': [1, 2, 3],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match='date_column "mydate" not found'):
|
||||
repo._configure_datetime_index(df, p, {})
|
||||
|
||||
|
||||
def test_configure_datetime_index_non_datetime_date_column_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict({**_minimal_dict_for_prepare(), 'date_column': 'mydate'})
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'mydate': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'v1': [1.0, 2.0, 3.0],
|
||||
't': [1.0, 2.0, 3.0],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match='must be datetime before index configuration'):
|
||||
repo._configure_datetime_index(df, p, {})
|
||||
|
||||
|
||||
def test_create_run_directory_permission_error():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
with patch(
|
||||
'model_manager.utils.repository.data_manager_repository.makedirs',
|
||||
side_effect=PermissionError('no'),
|
||||
):
|
||||
with pytest.raises(PermissionError, match='Permission denied'):
|
||||
repo._create_run_directory('/tmp', 'run', {})
|
||||
|
||||
|
||||
def test_create_run_directory_os_error():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
with patch(
|
||||
'model_manager.utils.repository.data_manager_repository.makedirs',
|
||||
side_effect=OSError('disk'),
|
||||
):
|
||||
with pytest.raises(OSError, match='Failed to create directory'):
|
||||
repo._create_run_directory('/tmp', 'run', {})
|
||||
|
||||
|
||||
def test_generate_report_success(tmp_path):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
y_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
run_name='testrun',
|
||||
)
|
||||
tmr.equation = {'target_variable': 't'}
|
||||
with (
|
||||
patch.object(repo, '_get_reports_directory', return_value=str(tmp_path)),
|
||||
patch('model_manager.utils.repository.data_manager_repository.Reports') as mrep,
|
||||
):
|
||||
instance = mrep.return_value
|
||||
instance.save_all_sections_html = Mock()
|
||||
out = repo.generate_report(tmr, {})
|
||||
assert out.report_path and out.train_data_path and out.test_data_path
|
||||
if out.equation_path:
|
||||
with open(out.equation_path, encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
|
||||
|
||||
def test_generate_report_adds_target_alias_for_reports(tmp_path):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
y_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
run_name='testrun',
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(repo, '_get_reports_directory', return_value=str(tmp_path)),
|
||||
patch('model_manager.utils.repository.data_manager_repository.Reports') as mrep,
|
||||
):
|
||||
instance = mrep.return_value
|
||||
instance.save_all_sections_html = Mock()
|
||||
out = repo.generate_report(tmr, {})
|
||||
|
||||
kwargs = mrep.call_args.kwargs
|
||||
reference_data = kwargs['reference_data']
|
||||
current_data = kwargs['current_data']
|
||||
assert 'target' in reference_data.columns
|
||||
assert 'target' in current_data.columns
|
||||
assert reference_data['target'].equals(reference_data['t'])
|
||||
assert current_data['target'].equals(current_data['t'])
|
||||
|
||||
assert out.train_data_path is not None
|
||||
assert out.test_data_path is not None
|
||||
train_csv = pd.read_csv(out.train_data_path)
|
||||
test_csv = pd.read_csv(out.test_data_path)
|
||||
assert 'target' in train_csv.columns
|
||||
assert 'target' in test_csv.columns
|
||||
assert train_csv['target'].equals(train_csv['t'])
|
||||
assert test_csv['target'].equals(test_csv['t'])
|
||||
|
||||
|
||||
def test_generate_report_skips_equation_file_when_not_linear(tmp_path):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_type = 'other'
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
val_data=pd.DataFrame({'v1': [1.0, 2.0], 't': [1.0, 2.0]}),
|
||||
y_train_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
y_pred=pd.DataFrame({'t': [1.0, 2.0]}),
|
||||
run_name='testrun',
|
||||
equation={'k': 'v'},
|
||||
)
|
||||
with (
|
||||
patch.object(repo, '_get_reports_directory', return_value=str(tmp_path)),
|
||||
patch('model_manager.utils.repository.data_manager_repository.Reports'),
|
||||
):
|
||||
out = repo.generate_report(tmr, {})
|
||||
assert out.equation_path is None
|
||||
|
||||
|
||||
def test_generate_report_run_name_missing():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0]}),
|
||||
val_data=pd.DataFrame({'t': [1.0]}),
|
||||
run_name=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match='run_name is not set'):
|
||||
repo.generate_report(tmr, {})
|
||||
|
||||
|
||||
def test_generate_report_requires_predictions():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0]}),
|
||||
val_data=pd.DataFrame({'t': [1.0]}),
|
||||
run_name='testrun',
|
||||
y_train_pred=None,
|
||||
y_pred=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match='y_train_pred or y_pred is not set'):
|
||||
repo.generate_report(tmr, {})
|
||||
|
||||
|
||||
def test_cleanup_run_directory_empty():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
repo.cleanup_run_directory('', {})
|
||||
|
||||
|
||||
def test_cleanup_run_directory_exists(tmp_path):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
d = tmp_path / 'subdir'
|
||||
d.mkdir()
|
||||
repo.cleanup_run_directory(str(d), {})
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_cleanup_run_directory_missing(tmp_path):
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
repo.cleanup_run_directory(str(tmp_path / 'nope'), {})
|
||||
|
||||
|
||||
def test_extract_model_equation_polynomial_poly_names():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_kwargs = {'degree': 2, 'poly_feature_names': ['f1', 'f2']}
|
||||
regr = MagicMock()
|
||||
regr.coef_ = np.array([1.0, 2.0])
|
||||
regr.intercept_ = 3.0
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = MagicMock()
|
||||
wrapper.model.regr = regr
|
||||
eq = repo._extract_model_equation(wrapper.model, p)
|
||||
assert 'equation_string' in eq and eq['degree'] == 2
|
||||
|
||||
|
||||
def test_extract_model_equation_extra_coefficients_ignored():
|
||||
"""More coefficients than feature names: only the first len(names) are used."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
regr = MagicMock()
|
||||
regr.coef_ = np.array([1.0, 2.0, 3.0])
|
||||
regr.intercept_ = 0.0
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = MagicMock()
|
||||
wrapper.model.regr = regr
|
||||
eq = repo._extract_model_equation(wrapper.model, p)
|
||||
assert len(eq['coefficients']) == len(p.variable_columns)
|
||||
|
||||
|
||||
def test_extract_model_equation_more_features_than_coefficients():
|
||||
"""Polynomial feature names longer than coef array: extra names get no coefficient entry."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_kwargs = {'degree': 2, 'poly_feature_names': ['a', 'b', 'c']}
|
||||
regr = MagicMock()
|
||||
regr.coef_ = np.array([1.0, 2.0])
|
||||
regr.intercept_ = 0.0
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = MagicMock()
|
||||
wrapper.model.regr = regr
|
||||
eq = repo._extract_model_equation(wrapper.model, p)
|
||||
assert list(eq['coefficients'].keys()) == ['a', 'b']
|
||||
|
||||
|
||||
def test_get_reports_directory_path():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
reports_dir = repo._get_reports_directory()
|
||||
assert reports_dir == REPORTS_ROOT
|
||||
163
tests/utils/test_connectors_config.py
Normal file
163
tests/utils/test_connectors_config.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from os import environ
|
||||
from unittest.mock import patch
|
||||
|
||||
from model_manager.utils.connectors_config import (
|
||||
build_minio_config,
|
||||
build_mlflow_config,
|
||||
build_mongodb_config,
|
||||
build_plugin_store_config,
|
||||
build_postgres_config,
|
||||
)
|
||||
|
||||
|
||||
def test_build_mlflow_config_with_env_vars():
|
||||
environ.pop('MLFLOW_URL', None)
|
||||
environ['MLFLOW_URL'] = 'http://test-host:8080'
|
||||
environ['MLFLOW_USERNAME'] = 'test-user'
|
||||
environ['MLFLOW_PASSWORD'] = 'test-pass'
|
||||
|
||||
config = build_mlflow_config()
|
||||
|
||||
assert config['url'] == 'http://test-host:8080'
|
||||
assert config['username'] == 'test-user'
|
||||
assert config['password'] == 'test-pass'
|
||||
|
||||
|
||||
def test_build_mlflow_config_with_defaults():
|
||||
environ.pop('MLFLOW_URL', None)
|
||||
environ.pop('MLFLOW_USERNAME', None)
|
||||
environ.pop('MLFLOW_PASSWORD', None)
|
||||
|
||||
config = build_mlflow_config()
|
||||
|
||||
assert config['url'] == 'http://localhost:5080'
|
||||
assert config['username'] == 'aignosi'
|
||||
assert config['password'] == 'aignosi'
|
||||
|
||||
|
||||
def test_build_postgres_config_with_env_vars():
|
||||
environ['POSTGRES_HOST'] = 'test-host'
|
||||
environ['POSTGRES_PORT'] = '5433'
|
||||
environ['POSTGRES_USER'] = 'test-user'
|
||||
environ['POSTGRES_PASSWORD'] = 'test-pass'
|
||||
environ['POSTGRES_DBNAME'] = 'test-db'
|
||||
environ['POSTGRES_MIN_CONNECTIONS'] = '10'
|
||||
environ['POSTGRES_MAX_CONNECTIONS'] = '30'
|
||||
|
||||
config = build_postgres_config()
|
||||
|
||||
assert config['host'] == 'test-host'
|
||||
assert config['port'] == 5433
|
||||
assert config['user'] == 'test-user'
|
||||
assert config['password'] == 'test-pass'
|
||||
assert config['dbname'] == 'test-db'
|
||||
assert config['min_connections'] == 10
|
||||
assert config['max_connections'] == 30
|
||||
|
||||
|
||||
def test_build_postgres_config_with_defaults():
|
||||
environ.pop('POSTGRES_HOST', None)
|
||||
environ.pop('POSTGRES_PORT', None)
|
||||
environ.pop('POSTGRES_USER', None)
|
||||
environ.pop('POSTGRES_PASSWORD', None)
|
||||
environ.pop('POSTGRES_DBNAME', None)
|
||||
environ.pop('POSTGRES_MIN_CONNECTIONS', None)
|
||||
environ.pop('POSTGRES_MAX_CONNECTIONS', None)
|
||||
|
||||
config = build_postgres_config()
|
||||
|
||||
assert config['host'] == 'localhost'
|
||||
assert config['port'] == 5432
|
||||
assert config['user'] == 'sientia'
|
||||
assert config['password'] == 'sientia'
|
||||
assert config['dbname'] == 'sientia'
|
||||
assert config['min_connections'] == 5
|
||||
assert config['max_connections'] == 20
|
||||
|
||||
|
||||
def test_build_mongo_db_config_with_env_vars():
|
||||
environ['MONGODB_USERNAME'] = 'sientia1'
|
||||
environ['MONGODB_PASSWORD'] = 'sientia1'
|
||||
environ['MONGODB_URL'] = 'localhost:27018'
|
||||
environ['MONGODB_DATABASE'] = 'test_db'
|
||||
environ['MONGODB_TTL_INDEX_HOURS'] = '1'
|
||||
|
||||
assert build_mongodb_config() == {
|
||||
'connection_string': 'mongodb://sientia1:sientia1@localhost:27018',
|
||||
'database_name': 'test_db',
|
||||
'ttl_index_seconds': 3600,
|
||||
'uri': 'localhost:27018',
|
||||
}
|
||||
|
||||
|
||||
def test_build_mongo_db_config_with_defaults():
|
||||
environ.pop('MONGODB_USERNAME', None)
|
||||
environ.pop('MONGODB_PASSWORD', None)
|
||||
environ.pop('MONGODB_DATABASE', None)
|
||||
environ.pop('MONGODB_URL', None)
|
||||
environ.pop('MONGODB_TTL_INDEX_HOURS', None)
|
||||
assert build_mongodb_config() == {
|
||||
'connection_string': 'mongodb://root:wKZDbMNU1c@localhost:27018',
|
||||
'database_name': 'sientia',
|
||||
'ttl_index_seconds': 3600,
|
||||
'uri': 'localhost:27018',
|
||||
}
|
||||
|
||||
|
||||
def test_build_minio_config_with_env_vars():
|
||||
environ['MINIO_ENDPOINT_URL'] = 'http://test-minio:9000'
|
||||
environ['MINIO_ACCESS_KEY'] = 'test-access-key'
|
||||
environ['MINIO_SECRET_KEY'] = 'test-secret-key'
|
||||
environ['MINIO_REGION'] = 'eu-west-1'
|
||||
environ['MINIO_SECURE'] = 'true'
|
||||
environ['MINIO_MAX_RETRY_ATTEMPTS'] = '5'
|
||||
environ['MINIO_RETRY_MODE'] = 'standard'
|
||||
environ['MINIO_CONNECT_TIMEOUT'] = '20'
|
||||
environ['MINIO_READ_TIMEOUT'] = '120'
|
||||
environ['MINIO_DEFAULT_BUCKET'] = 'my-bucket'
|
||||
|
||||
config = build_minio_config()
|
||||
|
||||
assert config['endpoint_url'] == 'http://test-minio:9000'
|
||||
assert config['access_key'] == 'test-access-key'
|
||||
assert config['secret_key'] == 'test-secret-key'
|
||||
assert config['region'] == 'eu-west-1'
|
||||
assert config['use_ssl'] is True
|
||||
assert config['max_retry_attempts'] == 5
|
||||
assert config['retry_mode'] == 'standard'
|
||||
assert config['connect_timeout'] == 20
|
||||
assert config['read_timeout'] == 120
|
||||
assert config['default_bucket'] == 'my-bucket'
|
||||
|
||||
|
||||
def test_build_plugin_store_config_cache_ttl_seconds():
|
||||
"""STORE_CACHE_TTL_SECONDS is parsed to int when set."""
|
||||
with patch.dict(environ, {'STORE_CACHE_TTL_SECONDS': '7200'}, clear=False):
|
||||
cfg = build_plugin_store_config()
|
||||
assert cfg['cache_ttl_seconds'] == 7200
|
||||
|
||||
|
||||
def test_build_minio_config_with_defaults():
|
||||
environ.pop('MINIO_ENDPOINT_URL', None)
|
||||
environ.pop('MINIO_ACCESS_KEY', None)
|
||||
environ.pop('MINIO_SECRET_KEY', None)
|
||||
environ.pop('MINIO_REGION', None)
|
||||
environ.pop('MINIO_SECURE', None)
|
||||
environ.pop('MINIO_MAX_RETRY_ATTEMPTS', None)
|
||||
environ.pop('MINIO_RETRY_MODE', None)
|
||||
environ.pop('MINIO_CONNECT_TIMEOUT', None)
|
||||
environ.pop('MINIO_READ_TIMEOUT', None)
|
||||
environ.pop('MINIO_DEFAULT_BUCKET', None)
|
||||
|
||||
config = build_minio_config()
|
||||
|
||||
assert config['endpoint_url'] == 'http://localhost:9000'
|
||||
assert config['access_key'] == 'minioadmin'
|
||||
assert config['secret_key'] == 'minioadmin'
|
||||
assert config['region'] == 'us-east-1'
|
||||
assert config['use_ssl'] is False
|
||||
assert config['max_retry_attempts'] == 3
|
||||
assert config['retry_mode'] == 'adaptive'
|
||||
assert config['connect_timeout'] == 10
|
||||
assert config['read_timeout'] == 60
|
||||
assert config['default_bucket'] == 'model-training'
|
||||
90
tests/utils/test_logger_helper.py
Normal file
90
tests/utils/test_logger_helper.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for logger_helper module with 100% coverage."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@patch('model_manager.utils.logger_helper.SientiaLogger')
|
||||
def test_get_logger_creates_logger_instance(mock_sientia_logger):
|
||||
"""Test get_logger creates a SientiaLogger instance with the given name."""
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
|
||||
mock_logger_instance = MagicMock()
|
||||
mock_logger_instance.base_logger = MagicMock()
|
||||
mock_sientia_logger.return_value = mock_logger_instance
|
||||
|
||||
result = get_logger('test_module')
|
||||
|
||||
mock_sientia_logger.assert_called_once_with('test_module')
|
||||
assert result is mock_logger_instance
|
||||
|
||||
|
||||
@patch('model_manager.utils.logger_helper.SientiaLogger')
|
||||
def test_get_logger_disables_propagation(mock_sientia_logger):
|
||||
"""Test get_logger disables log propagation."""
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
|
||||
mock_logger_instance = MagicMock()
|
||||
mock_base_logger = MagicMock()
|
||||
mock_base_logger.propagate = True
|
||||
mock_logger_instance.base_logger = mock_base_logger
|
||||
mock_sientia_logger.return_value = mock_logger_instance
|
||||
|
||||
get_logger('test_module')
|
||||
|
||||
assert mock_base_logger.propagate is False
|
||||
|
||||
|
||||
@patch('model_manager.utils.logger_helper.SientiaLogger')
|
||||
def test_get_logger_with_different_names(mock_sientia_logger):
|
||||
"""Test get_logger works with different logger names."""
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
|
||||
mock_logger_instance = MagicMock()
|
||||
mock_logger_instance.base_logger = MagicMock()
|
||||
mock_sientia_logger.return_value = mock_logger_instance
|
||||
|
||||
logger1 = get_logger('module1')
|
||||
logger2 = get_logger('module2')
|
||||
logger3 = get_logger('my.nested.module')
|
||||
|
||||
assert mock_sientia_logger.call_count == 3
|
||||
mock_sientia_logger.assert_any_call('module1')
|
||||
mock_sientia_logger.assert_any_call('module2')
|
||||
mock_sientia_logger.assert_any_call('my.nested.module')
|
||||
assert logger1 is mock_logger_instance
|
||||
assert logger2 is mock_logger_instance
|
||||
assert logger3 is mock_logger_instance
|
||||
|
||||
|
||||
@patch('model_manager.utils.logger_helper.SientiaLogger')
|
||||
def test_get_logger_with_empty_name(mock_sientia_logger):
|
||||
"""Test get_logger with empty string name."""
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
|
||||
mock_logger_instance = MagicMock()
|
||||
mock_logger_instance.base_logger = MagicMock()
|
||||
mock_sientia_logger.return_value = mock_logger_instance
|
||||
|
||||
result = get_logger('')
|
||||
|
||||
mock_sientia_logger.assert_called_once_with('')
|
||||
assert result is mock_logger_instance
|
||||
assert result.base_logger.propagate is False
|
||||
|
||||
|
||||
@patch('model_manager.utils.logger_helper.SientiaLogger')
|
||||
def test_get_logger_returns_configured_logger(mock_sientia_logger):
|
||||
"""Test get_logger returns the configured logger instance."""
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
|
||||
mock_logger_instance = MagicMock()
|
||||
mock_logger_instance.base_logger = MagicMock()
|
||||
mock_logger_instance.base_logger.propagate = True
|
||||
mock_sientia_logger.return_value = mock_logger_instance
|
||||
|
||||
result = get_logger('test_logger')
|
||||
|
||||
# Verify the logger is returned after configuration
|
||||
assert result is mock_logger_instance
|
||||
# Verify propagation was disabled
|
||||
assert mock_logger_instance.base_logger.propagate is False
|
||||
Reference in New Issue
Block a user