Coverage for src/lexigram/admin/settings/panel/registry.py: 31%
83 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Registry and persistence for configuration."""
3from __future__ import annotations
5import os
6from typing import TYPE_CHECKING, Any
8if TYPE_CHECKING:
9 from lexigram.admin.settings.panel.nodes import AbstractConfigNode, ConfigSpec
11__all__ = [
12 "ConfigRegistry",
13 "EnvStore",
14 "MemoryStore",
15 "StoreBase",
16]
19class StoreBase:
20 """Interface for configuration persistence."""
22 async def get(
23 self, key: str, default: Any = None, tenant_id: str | None = None
24 ) -> Any:
25 """Retrieve a value by key."""
26 return default
28 async def set(self, key: str, value: Any, tenant_id: str | None = None) -> None:
29 """Persist a value by key."""
32class EnvStore(StoreBase):
33 """Read-only store for environment variables."""
35 async def get(
36 self, key: str, default: Any = None, tenant_id: str | None = None
37 ) -> Any:
38 """Read a value from environment variables.
40 Converts internal dot-notation keys (e.g. ``app.db.url``) to
41 ``SCREAMING_SNAKE_CASE`` environment variable names.
42 """
43 env_key = key.upper().replace(".", "_")
44 return os.environ.get(env_key, default)
47class MemoryStore(StoreBase):
48 """In-memory store for testing."""
50 def __init__(self) -> None:
51 self._data: dict[str, Any] = {}
53 async def get(
54 self, key: str, default: Any = None, tenant_id: str | None = None
55 ) -> Any:
56 """Retrieve a value from the in-memory store."""
57 return self._data.get(key, default)
59 async def set(self, key: str, value: Any, tenant_id: str | None = None) -> None:
60 """Persist a value to the in-memory store."""
61 self._data[key] = value
64class ConfigRegistry:
65 """Central registry for specs and stores."""
67 def __init__(self) -> None:
68 self._specs: dict[str, type[ConfigSpec]] = {}
69 self._stores: dict[str, StoreBase] = {
70 "env": EnvStore(),
71 "default": MemoryStore(),
72 }
74 def register_store(self, name: str, store: StoreBase) -> None:
75 """Register a configuration store."""
76 self._stores[name] = store
78 def register_spec(self, spec: type[ConfigSpec]) -> None:
79 """Register a spec, grouped in the sidebar under its ``package_source``."""
80 if spec.namespace in self._specs:
81 return
82 self._specs[spec.namespace] = spec
84 def get_package_sources(self) -> list[str]:
85 """Return distinct package sources among specs with editable nodes, sorted."""
86 return sorted(
87 {spec.package_source for spec in self._specs.values() if spec.get_nodes()}
88 )
90 def get_specs_by_package(self, package_source: str) -> list[type[ConfigSpec]]:
91 """Get all registered specs for a package source that have editable nodes."""
92 return [
93 spec
94 for spec in self._specs.values()
95 if spec.package_source == package_source and spec.get_nodes()
96 ]
98 def get_spec(self, namespace: str) -> type[ConfigSpec] | None:
99 """Get a registered spec by namespace."""
100 return self._specs.get(namespace)
102 def has_store(self, name: str) -> bool:
103 """Check whether a store is registered."""
104 return name in self._stores
106 @classmethod
107 def with_defaults(cls) -> ConfigRegistry:
108 """Build a registry pre-populated with all built-in bound specs."""
109 registry = cls()
110 from lexigram.admin.settings.panel import (
111 register_branding_spec,
112 register_cache_spec,
113 register_deployment_spec,
114 register_features_spec,
115 register_i18n_spec,
116 register_profiler_spec,
117 register_rate_limit_spec,
118 register_rbac_spec,
119 register_security_spec,
120 )
122 register_branding_spec(registry)
123 register_cache_spec(registry)
124 register_security_spec(registry)
125 register_features_spec(registry)
126 register_i18n_spec(registry)
127 register_profiler_spec(registry)
128 register_rate_limit_spec(registry)
129 register_rbac_spec(registry)
130 register_deployment_spec(registry)
131 return registry
133 def get_node(self, full_key: str) -> AbstractConfigNode | None:
134 """Get a ConfigNode by its full key (namespace.key)."""
135 if "." not in full_key:
136 return None
138 # Try to find the longest matching namespace
139 for ns, spec in self._specs.items():
140 if full_key.startswith(ns + "."):
141 node_key = full_key[len(ns) + 1 :]
142 nodes = spec.get_nodes()
143 if node_key in nodes:
144 return nodes[node_key]
145 return None
147 async def get_values(
148 self,
149 namespace: str,
150 store_name: str = "default",
151 tenant_id: str | None = None,
152 ) -> dict[str, Any]:
153 """Load current values for a spec from a store."""
154 spec = self._specs.get(namespace)
155 if not spec:
156 return {}
158 store = self._stores.get(store_name, self._stores["default"])
159 values = {}
160 for key, node in spec.get_nodes().items():
161 full_key = f"{namespace}.{key}"
162 raw_val = await store.get(full_key, node.default, tenant_id=tenant_id)
163 values[key] = node.validate(raw_val)
164 return values
166 async def save_values(
167 self,
168 namespace: str,
169 values: dict[str, Any],
170 store_name: str = "default",
171 tenant_id: str | None = None,
172 ) -> None:
173 """Save values for a spec to a store, skipping readonly nodes."""
174 spec = self._specs.get(namespace)
175 if not spec:
176 return
178 store = self._stores.get(store_name, self._stores["default"])
179 nodes = spec.get_nodes()
180 for key, value in values.items():
181 if key in nodes and not nodes[key].readonly:
182 full_key = f"{namespace}.{key}"
183 validated = nodes[key].validate(value)
184 await store.set(full_key, validated, tenant_id=tenant_id)