Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add key/value storage feature #509

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions custom_components/spook/ectoplasms/spook/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
"""Spook - Not your homie."""


from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .storage import STORAGE_KEY, SpookKeyValueStore


async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Set up the Spook ectoplasm."""
# Initialize the Spook key/value store.
store = SpookKeyValueStore(hass)
await store.async_initialize()
hass.data[STORAGE_KEY] = store

return True
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

import voluptuous as vol

from homeassistant.helpers import config_validation as cv

from ....const import DOMAIN
from ....services import AbstractSpookAdminService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall


class SpookService(AbstractSpookAdminService):
"""Service to delete a value from the Spook key/value storage."""

domain = DOMAIN
service = "storage_delete"
schema = {
vol.Required("key"): cv.string,
}

async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
store.async_delete(call.data["key"])
26 changes: 26 additions & 0 deletions custom_components/spook/ectoplasms/spook/services/storage_dump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.core import SupportsResponse

from ....const import DOMAIN
from ....services import AbstractSpookService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall, ServiceResponse


class SpookService(AbstractSpookService):
"""Service dump all values in the Spook key/value storage."""

domain = DOMAIN
service = "storage_dump"
supports_response = SupportsResponse.ONLY

async def async_handle_service(self, _: ServiceCall) -> ServiceResponse:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
return store.async_dump()
23 changes: 23 additions & 0 deletions custom_components/spook/ectoplasms/spook/services/storage_flush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

from ....const import DOMAIN
from ....services import AbstractSpookAdminService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall


class SpookService(AbstractSpookAdminService):
"""Service to flush all stored key/values from the Spook key/value storage."""

domain = DOMAIN
service = "storage_flush"

async def async_handle_service(self, _: ServiceCall) -> None:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
store.async_flush()
26 changes: 26 additions & 0 deletions custom_components/spook/ectoplasms/spook/services/storage_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.core import SupportsResponse

from ....const import DOMAIN
from ....services import AbstractSpookService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall, ServiceResponse


class SpookService(AbstractSpookService):
"""Service to get all keys store in the Spook key/value storage."""

domain = DOMAIN
service = "storage_keys"
supports_response = SupportsResponse.ONLY

async def async_handle_service(self, _: ServiceCall) -> ServiceResponse:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
return {"keys": store.async_keys()}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

import voluptuous as vol

from homeassistant.core import SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv

from ....const import DOMAIN
from ....services import AbstractSpookService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall, ServiceResponse


class SpookService(AbstractSpookService):
"""Service to retrieve an item form the key/value storage."""

domain = DOMAIN
service = "storage_retrieve"
schema = {
vol.Required("key"): cv.string,
}
supports_response = SupportsResponse.ONLY

async def async_handle_service(self, call: ServiceCall) -> ServiceResponse:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
key = call.data["key"]
try:
return store.async_retrieve(key)
except KeyError as err:
msg = f"Key '{key}' not found in Spook's key/value store."
raise ServiceValidationError(msg) from err
36 changes: 36 additions & 0 deletions custom_components/spook/ectoplasms/spook/services/storage_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Spook - Not your homie."""
from __future__ import annotations

from typing import TYPE_CHECKING

import voluptuous as vol

from homeassistant.helpers import config_validation as cv

from ....const import DOMAIN
from ....services import AbstractSpookAdminService
from .. import STORAGE_KEY, SpookKeyValueStore

if TYPE_CHECKING:
from homeassistant.core import ServiceCall


class SpookService(AbstractSpookAdminService):
"""Service to store a value in the Spook key/value storage."""

domain = DOMAIN
service = "storage_store"
schema = {
vol.Required("key"): cv.string,
vol.Required("value"): cv.match_all,
vol.Optional("is_persistent"): cv.boolean,
vol.Optional("ttl"): cv.time_period,
}

async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
store: SpookKeyValueStore = self.hass.data[STORAGE_KEY]
data = call.data.copy()
if "ttl" in call.data:
data["ttl"] = call.data["ttl"].total_seconds()
store.async_store(**data)
121 changes: 121 additions & 0 deletions custom_components/spook/ectoplasms/spook/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Spook - Not your homie."""
from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, TypedDict

from homeassistant.core import callback
from homeassistant.helpers.storage import Store

if TYPE_CHECKING:
from homeassistant.core import HomeAssistant

_SENTINEL = object()

STORAGE_KEY = "spook_key_value_store"
STORAGE_MAJOR_VERSION = 1
STORAGE_MINOR_VERSION = 1


class SpookKeyValueStoreItem(TypedDict):
"""Spook key/value store item."""

key: str
last_modified: datetime | None
is_persistent: bool
ttl: int | None
value: Any


class SpookKeyValueStore:
"""Key/Value storage for Spook."""

_persistent_storage: Store[str, SpookKeyValueStoreItem]
_store: dict[str, SpookKeyValueStoreItem]

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the Spook key/value store."""
self._hass = hass
self._persistent_storage = Store(
hass,
key=STORAGE_KEY,
version=STORAGE_MAJOR_VERSION,
minor_version=STORAGE_MINOR_VERSION,
atomic_writes=True,
private=True,
)

async def async_initialize(self) -> None:
"""Initialize the Spook key/value store."""
data = await self._persistent_storage.async_load()
self._store = data or {}

@callback
def async_retrieve(self, key: str) -> SpookKeyValueStoreItem:
"""Get a value from the store."""
# If item is not found, raise an exception
# Also, if an item is found but is expired (last_modified + ttl is passed now), raise an exception
if (
not (item := self._store.get(key))
and item["ttl"] is not None
and item["last_modified"] + timedelta(seconds=item["ttl"])
< datetime.now(tz=UTC)
):
msg = f"Key {key} not found in Spook's key/value store"
raise KeyError(msg)
return item

@callback
def _persistent_items(self) -> dict[str, SpookKeyValueStoreItem]:
"""Get all persistent items from the store."""
return {key: item for key, item in self._store.items() if item["persistent"]}

@callback
def async_store(
self,
key: str,
value: Any,
is_persistent: bool | None = None,
ttl: int | None = _SENTINEL,
) -> None:
"""Set a value in the store."""
if item := self._store.get(key):
if is_persistent is not None:
item["is_persistent"] = is_persistent
if ttl is not _SENTINEL:
item["ttl"] = ttl
item["value"] = value
item["last_modified"] = datetime.now(tz=UTC)
else:
item = SpookKeyValueStoreItem(
key=key,
value=value,
is_persistent=is_persistent or True,
ttl=None if _SENTINEL else ttl,
last_modified=datetime.now(tz=UTC),
)
self._store[key] = item
self._persistent_storage.async_delay_save(self._persistent_items, 30)

@callback
def async_flush(self) -> None:
"""Flush the store."""
self._store = {}
self._persistent_storage.async_delay_save(self._persistent_items, 30)

@callback
def async_delete(self, key: str) -> None:
"""Delete a value from the store."""
if key in self._store:
del self._store[key]
self._persistent_storage.async_delay_save(self._store, 30)

@callback
def async_dump(self) -> dict[str, SpookKeyValueStoreItem]:
"""Dump the store."""
return self._store

@callback
def async_keys(self) -> list[str]:
"""Get all keys in the store."""
return list(self._store)
Loading