Skip to content

Commit

Permalink
initial integration tests (#23)
Browse files Browse the repository at this point in the history
* initial integration tests

* update ini path

* add required env vars

* fix unbound local
  • Loading branch information
akshaya-a authored Mar 10, 2024
1 parent 6daf2a2 commit dd1208d
Show file tree
Hide file tree
Showing 24 changed files with 488 additions and 213 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Addon Integration

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v3
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f mindctrl-addon/test-requirements.txt ]; then pip install -r mindctrl-addon/test-requirements.txt; fi
- name: Lint with Ruff
run: |
ruff check .
- name: Test with pytest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
pytest -v -s -c mindctrl-addon/tests/pytest.ini
17 changes: 17 additions & 0 deletions .github/workflows/k3s.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: k3s

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
some-job:
steps:
- uses: nolar/setup-k3d-k3s@v1
with:
version: v1.21 # E.g.: v1.21, v1.21.2, v1.21.2+k3s1
github-token: ${{ secrets.GITHUB_TOKEN }}
26 changes: 6 additions & 20 deletions custom_components/mindctrl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,21 @@

from __future__ import annotations

from functools import partial
import logging

import mlflow as mlflowlib

import voluptuous as vol

from homeassistant.components import conversation as haconversation
from homeassistant.components.hassio import AddonManager, AddonError, AddonState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import (
HomeAssistant,
SupportsResponse,
callback
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

import asyncio

from .addon import get_addon_manager

from .const import (
ADDON_NAME, CONF_URL, CONF_USE_ADDON, DOMAIN, _LOGGER
)
from .const import ADDON_NAME, CONF_URL, CONF_USE_ADDON, DOMAIN, _LOGGER

from .services import MindctrlClient, async_register_services
from .conversation import MLflowAgent
Expand All @@ -53,11 +39,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return True


async def update_listener(hass, entry):
"""Handle options update."""
# https://developers.home-assistant.io/docs/config_entries_options_flow_handler#signal-updates
_LOGGER.error(f"update_listener {entry}")


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MLflow from a config entry."""
_LOGGER.error("mindctrl async_setup_entry")
Expand Down Expand Up @@ -125,8 +113,7 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) ->

addon_state = addon_info.state

addon_config = {
}
addon_config = {}

if addon_state == AddonState.NOT_INSTALLED:
addon_manager.async_schedule_install_setup_addon(
Expand All @@ -142,7 +129,6 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) ->
)
raise ConfigEntryNotReady

addon_options = addon_info.options
updates = {}
if updates:
hass.config_entries.async_update_entry(entry, data={**entry.data, **updates})
Expand Down
21 changes: 3 additions & 18 deletions custom_components/mindctrl/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
from typing import Any
from homeassistant import config_entries, exceptions
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.components.hassio import (
AddonError,
AddonInfo,
AddonManager,
AddonState,
HassioServiceInfo,
is_hassio,
)

# from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.data_entry_flow import (
AbortFlow,
FlowHandler,
FlowManager,
FlowResult,
)
import voluptuous as vol
Expand All @@ -28,15 +16,12 @@
from .const import (
ADDON_HOST,
ADDON_PORT,
ADDON_SLUG,
CONF_ADDON_LOG_LEVEL,
CONF_INTEGRATION_CREATED_ADDON,
DOMAIN,
_LOGGER,
CONF_USE_ADDON,
CONF_URL,
)
from .addon import get_addon_manager


DEFAULT_URL = f"http://{ADDON_HOST}:{ADDON_PORT}"
Expand Down Expand Up @@ -171,7 +156,7 @@ async def async_step_manual(
errors = {}

try:
version_info = await validate_input(self.hass, user_input)
_ = await validate_input(self.hass, user_input)
except InvalidInput as err:
errors["base"] = err.error
except Exception: # pylint: disable=broad-except
Expand Down
4 changes: 3 additions & 1 deletion custom_components/mindctrl/entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""AdGuard Home base entity."""

from __future__ import annotations
from abc import ABC, abstractmethod

Expand All @@ -9,6 +10,7 @@
from .const import ADDON_SLUG, DOMAIN, _LOGGER
from .services import MindctrlClient


# https://github.com/home-assistant/core/blob/52d27230bce239017722d8ce9dd6f5386f63aba2/homeassistant/components/adguard/entity.py
class MindctrlEntity(Entity, ABC):
"""Defines a base Mindctrl entity."""
Expand Down Expand Up @@ -69,7 +71,7 @@ def device_info(self) -> DeviceInfo:
manufacturer="AK",
name="Mindctrl",
sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get(
DATA_MINDCTRL_VERSION
"version", "unknown"
),
configuration_url=config_url,
)
2 changes: 1 addition & 1 deletion custom_components/mindctrl/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import mlflow
from .const import DOMAIN, SERVICE_INVOKE_MODEL, _LOGGER, CONF_URL
import voluptuous as vol
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers import config_validation as cv


class MindctrlClient(object):
Expand Down
1 change: 1 addition & 0 deletions mindctrl-addon/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

mlflow[genai]~=2.10
pydantic<3,>=1.0
pydantic-settings~=2.2
fastapi<1
uvicorn[standard]<1
watchfiles<1
Expand Down
42 changes: 42 additions & 0 deletions mindctrl-addon/rootfs/usr/bin/multiserver/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import Optional, Union, Literal
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


# this is just to make settings typing happy - I don't have another implementation yet
class UnknownEventsSettings(BaseModel):
events_type: Literal["unknown"]

class MqttEventsSettings(BaseModel):
events_type: Literal["mqtt"]

broker: str = "localhost"
port: int = 1883
username: Optional[str] = None
password: Optional[SecretStr] = None

class PostgresStoreSettings(BaseModel):
store_type: Literal["psql"]

user: str
password: SecretStr
address: str = "localhost"
port: int = 5432
database: str = "mindctrl"

# Just to make typing happy for now - add dapr, sqlite, etc
class UnknownStoreSettings(BaseModel):
store_type: Literal["unknown"]

class AppSettings(BaseSettings):
# double underscore, in case your font doesn't make it clear
model_config = SettingsConfigDict(env_nested_delimiter='__')

store: Union[PostgresStoreSettings, UnknownStoreSettings] = Field(discriminator="store_type")
events: Union[MqttEventsSettings, UnknownEventsSettings] = Field(discriminator="events_type")
# TODO: move this into the gateway or something
openai_api_key: SecretStr
force_publish_models: bool = False
notify_fd: Optional[int] = None
include_challenger_models: bool = True
mlflow_tracking_uri: Optional[str] = None
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import datetime

from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.dialects.postgresql.types import TIMESTAMP
from pgvector.sqlalchemy import Vector


EMBEDDING_DIM = 384
Expand Down
1 change: 0 additions & 1 deletion mindctrl-addon/rootfs/usr/bin/multiserver/db/queries.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
TABLE_NAME = "summary_data"
from .models.summary_data import EMBEDDING_DIM

# CREATE_TABLE = """CREATE TABLE IF NOT EXISTS {table_name}
# (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,33 @@
import collections
import json
import logging
import os

from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
AsyncEngine,
)

from .queries import (
ADD_RETENTION_POLICY,
CREATE_SUMMARY_TABLE,
ENABLE_PGVECTOR,
CONVERT_TO_HYPERTABLE,
ENABLE_TIMESCALE,
)

from config import PostgresStoreSettings
from mlmodels import summarize_events

_LOGGER = logging.getLogger(__name__)

_LOGGER = logging.getLogger(__name__)

def get_connection_string(include_password: bool = False) -> str:
username = os.environ.get("POSTGRES_USER")
password = os.environ["POSTGRES_PASSWORD"]
address = os.environ.get("POSTGRES_ADDRESS", "localhost")
port = os.environ.get("POSTGRES_PORT", "5432")
database = os.environ.get("POSTGRES_DATABASE", "mindctrl")
return f"postgresql+asyncpg://{username}:{password if include_password else '****'}@{address}:{port}/{database}"

def get_connection_string(
settings: PostgresStoreSettings, include_password: bool = False
) -> str:
return f"postgresql+asyncpg://{settings.user}:{settings.password.get_secret_value() if include_password else settings.password}@{settings.address}:{settings.port}/{settings.database}"

DATABASE_URL = get_connection_string(include_password=True)
DATABASE_SAFE_URL = get_connection_string(include_password=False)
_LOGGER.info(f"Using database: {DATABASE_SAFE_URL}")

engine: AsyncEngine = create_async_engine(DATABASE_URL, future=True, echo=True)
# Don't need this until we have real models
# async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)
# TODO: Go use real models later
Expand All @@ -46,7 +37,14 @@ def get_connection_string(include_password: bool = False) -> str:
# return async_session


async def setup_db() -> AsyncEngine:
async def setup_db(settings: PostgresStoreSettings) -> AsyncEngine:
connection_string = get_connection_string(settings, include_password=True)
_LOGGER.info(
f"Using database: {get_connection_string(settings, include_password=False)}"
)

engine: AsyncEngine = create_async_engine(connection_string, future=True, echo=True)

async with engine.begin() as conn:
await conn.execute(text(ENABLE_TIMESCALE))
await conn.execute(text(ENABLE_PGVECTOR))
Expand All @@ -60,17 +58,18 @@ async def setup_db() -> AsyncEngine:

# TODO: move the relevant stuff to rag interface
# TODO: probably rename to mlmodels to reduce confusion with dbmodels
from mlmodels import summarize_events, embed_summary
from .models.summary_data import EMBEDDING_DIM


async def insert_summary(state_ring_buffer: collections.deque[dict]):
async def insert_summary(
engine: AsyncEngine,
include_challenger: bool,
state_ring_buffer: collections.deque[dict],
):
print("Inserting summary")
# TODO: do this better as a batch insert
# use summarizer model to emit a LIST of summaries, each with the timestamp from relevant event
events = [json.dumps(event) for event in list(state_ring_buffer)]
# summarized_events = summarize_events(events)
include_challenger = bool(os.environ.get("INCLUDE_CHALLENGER", True))
champion_summary, challenger_summary = summarize_events(
["\n".join(events)], include_challenger
)
Expand Down
Loading

0 comments on commit dd1208d

Please sign in to comment.