2025-12-11 19:04:02 -08:00
|
|
|
"""Store registry.
|
|
|
|
|
|
|
|
|
|
Concrete store implementations live in the `Store/` package.
|
|
|
|
|
This module is the single source of truth for store discovery.
|
|
|
|
|
|
2025-12-13 00:18:30 -08:00
|
|
|
This registry is config-driven:
|
|
|
|
|
- Each store subtype (e.g. `hydrusnetwork`) maps to a concrete store class.
|
|
|
|
|
- Each store class advertises its required config keys via `StoreClass.__new__.keys`.
|
|
|
|
|
- Instances are created from config using those keys (case-insensitive lookup).
|
2025-12-11 19:04:02 -08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2025-12-13 00:18:30 -08:00
|
|
|
import importlib
|
|
|
|
|
import inspect
|
|
|
|
|
import pkgutil
|
2025-12-11 19:04:02 -08:00
|
|
|
from pathlib import Path
|
2025-12-13 00:18:30 -08:00
|
|
|
from typing import Any, Dict, Iterable, Optional, Type
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
from SYS.logger import debug
|
|
|
|
|
|
2025-12-11 23:21:45 -08:00
|
|
|
from Store._base import Store as BaseStore
|
2025-12-13 00:18:30 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_store_type(value: str) -> str:
|
|
|
|
|
return "".join(ch.lower() for ch in str(value or "") if ch.isalnum())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_config_key(value: str) -> str:
|
|
|
|
|
return str(value or "").strip().upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_case_insensitive(mapping: Dict[str, Any], key: str) -> Any:
|
|
|
|
|
if key in mapping:
|
|
|
|
|
return mapping[key]
|
|
|
|
|
desired = _normalize_config_key(key)
|
|
|
|
|
for k, v in mapping.items():
|
|
|
|
|
if _normalize_config_key(k) == desired:
|
|
|
|
|
return v
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _discover_store_classes() -> Dict[str, Type[BaseStore]]:
|
|
|
|
|
"""Discover store classes from the Store package.
|
|
|
|
|
|
|
|
|
|
Convention:
|
|
|
|
|
- The store type key is the normalized class name (e.g. HydrusNetwork -> hydrusnetwork).
|
|
|
|
|
"""
|
|
|
|
|
import Store as store_pkg
|
|
|
|
|
|
|
|
|
|
discovered: Dict[str, Type[BaseStore]] = {}
|
|
|
|
|
for module_info in pkgutil.iter_modules(store_pkg.__path__):
|
|
|
|
|
module_name = module_info.name
|
|
|
|
|
if module_name in {"__init__", "_base", "registry"}:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
module = importlib.import_module(f"Store.{module_name}")
|
|
|
|
|
for _, obj in vars(module).items():
|
|
|
|
|
if not inspect.isclass(obj):
|
|
|
|
|
continue
|
|
|
|
|
if obj is BaseStore:
|
|
|
|
|
continue
|
|
|
|
|
if not issubclass(obj, BaseStore):
|
|
|
|
|
continue
|
|
|
|
|
discovered[_normalize_store_type(obj.__name__)] = obj
|
|
|
|
|
return discovered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]:
|
|
|
|
|
keys = getattr(store_cls.__new__, "keys", None)
|
|
|
|
|
if keys is None:
|
|
|
|
|
return []
|
|
|
|
|
if isinstance(keys, dict):
|
|
|
|
|
return [str(k) for k in keys.keys()]
|
|
|
|
|
if isinstance(keys, (list, tuple, set, frozenset)):
|
|
|
|
|
return [str(k) for k in keys]
|
|
|
|
|
if isinstance(keys, str):
|
|
|
|
|
return [keys]
|
|
|
|
|
raise TypeError(f"Unsupported __new__.keys type for {store_cls.__name__}: {type(keys)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_kwargs(store_cls: Type[BaseStore], instance_name: str, instance_config: Any) -> Dict[str, Any]:
|
|
|
|
|
if isinstance(instance_config, dict):
|
|
|
|
|
cfg_dict = dict(instance_config)
|
|
|
|
|
else:
|
|
|
|
|
cfg_dict = {}
|
|
|
|
|
|
|
|
|
|
required = _required_keys_for(store_cls)
|
|
|
|
|
|
|
|
|
|
# If NAME is required but not present, allow the instance key to provide it.
|
|
|
|
|
if any(_normalize_config_key(k) == "NAME" for k in required) and _get_case_insensitive(cfg_dict, "NAME") is None:
|
|
|
|
|
cfg_dict["NAME"] = str(instance_name)
|
|
|
|
|
|
|
|
|
|
kwargs: Dict[str, Any] = {}
|
|
|
|
|
missing: list[str] = []
|
|
|
|
|
for key in required:
|
|
|
|
|
value = _get_case_insensitive(cfg_dict, key)
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
missing.append(str(key))
|
|
|
|
|
continue
|
|
|
|
|
kwargs[str(key)] = value
|
|
|
|
|
|
|
|
|
|
if missing:
|
|
|
|
|
raise ValueError(f"Missing required keys for {store_cls.__name__}: {', '.join(missing)}")
|
|
|
|
|
|
|
|
|
|
return kwargs
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Store:
|
|
|
|
|
def __init__(self, config: Optional[Dict[str, Any]] = None, suppress_debug: bool = False) -> None:
|
|
|
|
|
self._config = config or {}
|
|
|
|
|
self._suppress_debug = suppress_debug
|
2025-12-11 23:21:45 -08:00
|
|
|
self._backends: Dict[str, BaseStore] = {}
|
2025-12-11 19:04:02 -08:00
|
|
|
self._load_backends()
|
|
|
|
|
|
|
|
|
|
def _load_backends(self) -> None:
|
|
|
|
|
store_cfg = self._config.get("store")
|
|
|
|
|
if not isinstance(store_cfg, dict):
|
|
|
|
|
store_cfg = {}
|
|
|
|
|
|
2025-12-13 00:18:30 -08:00
|
|
|
classes_by_type = _discover_store_classes()
|
|
|
|
|
for raw_store_type, instances in store_cfg.items():
|
|
|
|
|
if not isinstance(instances, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
store_type = _normalize_store_type(str(raw_store_type))
|
|
|
|
|
store_cls = classes_by_type.get(store_type)
|
|
|
|
|
if store_cls is None:
|
|
|
|
|
if not self._suppress_debug:
|
|
|
|
|
debug(f"[Store] Unknown store type '{raw_store_type}'")
|
|
|
|
|
continue
|
2025-12-11 19:04:02 -08:00
|
|
|
|
2025-12-13 00:18:30 -08:00
|
|
|
for instance_name, instance_config in instances.items():
|
2025-12-11 19:04:02 -08:00
|
|
|
try:
|
2025-12-13 00:18:30 -08:00
|
|
|
kwargs = _build_kwargs(store_cls, str(instance_name), instance_config)
|
|
|
|
|
|
|
|
|
|
# Convenience normalization for filesystem-like paths.
|
|
|
|
|
for key in list(kwargs.keys()):
|
|
|
|
|
if _normalize_config_key(key) in {"PATH", "LOCATION"}:
|
|
|
|
|
kwargs[key] = str(Path(str(kwargs[key])).expanduser())
|
|
|
|
|
|
|
|
|
|
backend = store_cls(**kwargs)
|
|
|
|
|
|
|
|
|
|
backend_name = str(kwargs.get("NAME") or instance_name)
|
|
|
|
|
self._backends[backend_name] = backend
|
2025-12-11 19:04:02 -08:00
|
|
|
except Exception as exc:
|
|
|
|
|
if not self._suppress_debug:
|
2025-12-13 00:18:30 -08:00
|
|
|
debug(
|
|
|
|
|
f"[Store] Failed to register {store_cls.__name__} instance '{instance_name}': {exc}"
|
|
|
|
|
)
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
def list_backends(self) -> list[str]:
|
|
|
|
|
return sorted(self._backends.keys())
|
|
|
|
|
|
|
|
|
|
def list_searchable_backends(self) -> list[str]:
|
|
|
|
|
searchable: list[str] = []
|
|
|
|
|
for name, backend in self._backends.items():
|
2025-12-11 23:21:45 -08:00
|
|
|
if type(backend).search is not BaseStore.search:
|
2025-12-11 19:04:02 -08:00
|
|
|
searchable.append(name)
|
|
|
|
|
return sorted(searchable)
|
|
|
|
|
|
2025-12-11 23:21:45 -08:00
|
|
|
def __getitem__(self, backend_name: str) -> BaseStore:
|
2025-12-11 19:04:02 -08:00
|
|
|
if backend_name not in self._backends:
|
|
|
|
|
raise KeyError(f"Unknown store backend: {backend_name}. Available: {list(self._backends.keys())}")
|
|
|
|
|
return self._backends[backend_name]
|
|
|
|
|
|
|
|
|
|
def is_available(self, backend_name: str) -> bool:
|
|
|
|
|
return backend_name in self._backends
|