feat: require date_column in training parameters and update documentation
- Made `date_column` a required field in `TrainModelParams`, ensuring it must be present in the input data. - Updated related documentation in `input-sample.md`, `README.md`, and various test scenarios to reflect the change in requirement. - Adjusted the handling of `date_format` to default to `yyyy-MM-dd HH:mm:ss` if omitted, enhancing usability. - Refined test scenarios to include new examples and ensure compliance with the updated parameter structure. These changes improve the robustness of the model training workflow and clarify the expectations for input data.
This commit is contained in:
164
e2e/conftest.py
164
e2e/conftest.py
@@ -1,27 +1,25 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for E2E tests.
|
||||
|
||||
All external dependencies use real services:
|
||||
- PostgreSQL: testcontainers (postgres:15)
|
||||
- MinIO: testcontainers (minio)
|
||||
- MongoDB: testcontainers (mongo:7)
|
||||
- MLflow: local filesystem tracking (no network)
|
||||
- Gitea: testcontainers generic container (gitea/gitea:latest),
|
||||
seeded with model-plugin-warehouse files via REST API
|
||||
- Temporal: in-memory WorkflowEnvironment (time-skipping)
|
||||
External dependencies use testcontainers or real SDK integrations (no mocks of
|
||||
model_manager or other first-party code):
|
||||
|
||||
- PostgreSQL, MinIO, MongoDB, Gitea: testcontainers.
|
||||
- MLflow: real client with ``file://`` tracking URI (no MLflow server process).
|
||||
- Temporal: ``WorkflowEnvironment.start_time_skipping()`` — official in-process
|
||||
test runtime from temporalio; exercises real workflows and activity code, not
|
||||
stubs of business logic.
|
||||
- Observability: ``Logger`` (``get_logger`` from ``model_manager.utils.logger_helper``)
|
||||
and ``MetricsController`` from sientia_do, same stack as production.
|
||||
"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import mlflow
|
||||
import pytest
|
||||
@@ -37,6 +35,7 @@ from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from model_manager.activities.activities import Activities
|
||||
from model_manager.utils.logger_helper import get_logger
|
||||
from model_manager.workflows.cleanup_files import CleanupFiles
|
||||
from model_manager.workflows.train_model import TrainModel
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||
@@ -44,12 +43,6 @@ from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_model.model_repository.plugin_store import PluginStore
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
_WAREHOUSE_ROOT = Path(
|
||||
'/home/grezewave/Documents/projects/sientia/model-plugin-warehouse'
|
||||
)
|
||||
|
||||
# CSV training data: columns must match the variable_columns and target_variable
|
||||
# used across all test scenarios.
|
||||
_TRAIN_CSV_COLUMNS = [
|
||||
@@ -107,6 +100,63 @@ def _build_training_csv_dd_mm_yyyy() -> bytes:
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def _build_training_csv_custom_target_column() -> bytes:
|
||||
"""
|
||||
Same layout as the standard CSV but the target column has a non-default name
|
||||
(not ``target``) to exercise report and metrics paths.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
'timestamp',
|
||||
'303-WIT-200(Value)',
|
||||
'MY_CUSTOM_TARGET_COLUMN',
|
||||
])
|
||||
for i in range(150):
|
||||
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||
target_val = round(100.0 + (i % 15) * 0.3, 2)
|
||||
writer.writerow([ts, wit200, target_val])
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def _build_training_csv_timestamp_header_naive() -> bytes:
|
||||
"""
|
||||
Naive datetimes under column ``Timestamp`` (common UI export) for scenario 16.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
'Timestamp',
|
||||
'303-WIT-200(Value)',
|
||||
'03CV020/CORRENTE_N_M1_PV(Value)',
|
||||
])
|
||||
for i in range(150):
|
||||
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||
cv020 = round(100.0 + (i % 15) * 0.3, 2)
|
||||
writer.writerow([ts, wit200, cv020])
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def _build_training_csv_blank_timestamp_row() -> bytes:
|
||||
"""Standard columns with one row where ``timestamp`` is empty (NaN after parse)."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(_TRAIN_CSV_COLUMNS)
|
||||
for i in range(150):
|
||||
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||
cv020 = round(100.0 + (i % 15) * 0.3, 2)
|
||||
wit230 = round(25.0 + (i % 18) * 0.4, 2)
|
||||
cv022 = round(90.0 + (i % 12) * 0.25, 2)
|
||||
if i == 17:
|
||||
writer.writerow(['', wit200, cv020, wit230, cv022])
|
||||
else:
|
||||
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||
writer.writerow([ts, wit200, cv020, wit230, cv022])
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers – Gitea seed
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,7 +347,8 @@ def mongodb_container():
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
def gitea_container():
|
||||
"""
|
||||
Gitea container seeded with the model-plugin-warehouse files.
|
||||
Gitea container with a ``model-store`` repo seeded via REST API
|
||||
(``index.yaml``, dummy model files, ``_seed_gitea``).
|
||||
|
||||
The container starts with INSTALL_LOCK so no setup wizard is needed.
|
||||
An admin user is created via Gitea's CLI before the HTTP API is used.
|
||||
@@ -318,7 +369,6 @@ def gitea_container():
|
||||
base_url = f'http://localhost:{port}'
|
||||
|
||||
_wait_for_gitea(base_url)
|
||||
import time
|
||||
time.sleep(5) # Wait a bit for DB to fully initialize after HTTP is up
|
||||
|
||||
# Create admin user via Gitea CLI inside the container
|
||||
@@ -397,6 +447,33 @@ def upload_training_csv(minio_container, mlflow_tracking_dir): # noqa: ARG001
|
||||
content_type='text/csv',
|
||||
)
|
||||
|
||||
custom_target = _build_training_csv_custom_target_column()
|
||||
client.put_object(
|
||||
_MINIO_BUCKET,
|
||||
'training_data_custom_target.csv',
|
||||
io.BytesIO(custom_target),
|
||||
length=len(custom_target),
|
||||
content_type='text/csv',
|
||||
)
|
||||
|
||||
ts_header = _build_training_csv_timestamp_header_naive()
|
||||
client.put_object(
|
||||
_MINIO_BUCKET,
|
||||
'training_data_timestamp_naive.csv',
|
||||
io.BytesIO(ts_header),
|
||||
length=len(ts_header),
|
||||
content_type='text/csv',
|
||||
)
|
||||
|
||||
blank_ts = _build_training_csv_blank_timestamp_row()
|
||||
client.put_object(
|
||||
_MINIO_BUCKET,
|
||||
'training_data_blank_timestamp_row.csv',
|
||||
io.BytesIO(blank_ts),
|
||||
length=len(blank_ts),
|
||||
content_type='text/csv',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Function-scoped: database engine + schema setup
|
||||
@@ -437,36 +514,27 @@ def setup_experiment_run_table(postgres_engine):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock-only fixtures (no external service equivalent)
|
||||
# Observability (real sientia_do implementations)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def mock_logger():
|
||||
"""Minimal logger that prints to stdout (no external observability needed)."""
|
||||
def _log(msg, *args, **kwargs): # noqa: ARG001
|
||||
print(f'[LOG] {msg}')
|
||||
|
||||
logger = MagicMock()
|
||||
for method in ('info', 'debug', 'error', 'warning', 'critical',
|
||||
'custom_info', 'custom_debug', 'custom_error',
|
||||
'custom_warning', 'custom_critical'):
|
||||
setattr(logger, method, MagicMock(side_effect=_log))
|
||||
logger.base_logger = MagicMock()
|
||||
return logger
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
def e2e_logger():
|
||||
"""Shared production-style Logger for the whole E2E session."""
|
||||
return get_logger('model-manager-e2e')
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def mock_metrics_controller(mock_logger):
|
||||
"""Real MetricsController backed by the mock logger."""
|
||||
return MetricsController(logger=mock_logger)
|
||||
def metrics_controller(e2e_logger):
|
||||
"""MetricsController bound to the E2E logger (fresh instance per test)."""
|
||||
return MetricsController(logger=e2e_logger)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real application fixtures
|
||||
# Application fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def notification_handler(mongodb_container, mock_logger):
|
||||
def notification_handler(mongodb_container, e2e_logger):
|
||||
"""
|
||||
Real CoreNotificationHandler connected to the MongoDB testcontainer.
|
||||
"""
|
||||
@@ -474,7 +542,7 @@ def notification_handler(mongodb_container, mock_logger):
|
||||
handler = CoreNotificationHandler(
|
||||
connection_string=connection_url,
|
||||
database='test_notifications',
|
||||
logger=mock_logger,
|
||||
logger=e2e_logger,
|
||||
project_name='model-manager-e2e',
|
||||
)
|
||||
yield handler
|
||||
@@ -482,7 +550,7 @@ def notification_handler(mongodb_container, mock_logger):
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def plugin_store(gitea_container, mock_logger, mock_metrics_controller, notification_handler):
|
||||
def plugin_store(gitea_container, e2e_logger, metrics_controller, notification_handler):
|
||||
"""
|
||||
Real PluginStore pointed at the Gitea testcontainer.
|
||||
cache_ttl_seconds=0 forces a fresh download every test.
|
||||
@@ -494,9 +562,9 @@ def plugin_store(gitea_container, mock_logger, mock_metrics_controller, notifica
|
||||
username=gitea_container['admin_user'],
|
||||
password=gitea_container['admin_pass'],
|
||||
cache_ttl_seconds=0,
|
||||
logger=mock_logger,
|
||||
logger=e2e_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=mock_metrics_controller,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
yield store
|
||||
|
||||
@@ -507,9 +575,9 @@ def test_activities(
|
||||
minio_container,
|
||||
mlflow_tracking_dir, # noqa: ARG001 – ensures MLflow URI is set
|
||||
plugin_store,
|
||||
mock_logger,
|
||||
e2e_logger,
|
||||
notification_handler,
|
||||
mock_metrics_controller,
|
||||
metrics_controller,
|
||||
):
|
||||
"""
|
||||
Real Activities instance wired to all testcontainers.
|
||||
@@ -540,9 +608,9 @@ def test_activities(
|
||||
'default_bucket': _MINIO_BUCKET,
|
||||
},
|
||||
plugin_store=plugin_store,
|
||||
logger=mock_logger,
|
||||
logger=e2e_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=mock_metrics_controller,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
yield activities
|
||||
activities.shutdown()
|
||||
@@ -561,7 +629,7 @@ def _activity_list(activities: Activities) -> list:
|
||||
|
||||
@pytest_asyncio.fixture(scope='function')
|
||||
async def temporal_test_env():
|
||||
"""In-memory Temporal environment with time-skipping."""
|
||||
"""Temporal SDK test environment (time-skipping); runs real workflow/activity code."""
|
||||
env = await WorkflowEnvironment.start_time_skipping()
|
||||
async with env:
|
||||
yield env
|
||||
|
||||
Reference in New Issue
Block a user