Coverage for src/lexigram/admin/multitenancy/adapter.py: 93%
92 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Adapters that delegate admin multitenancy to lexigram-tenancy.
3When ``lexigram-tenancy`` is installed, ``TenantProviderRegistry`` wraps a
4``TenantProviderProtocol`` store and provides the same API as
5``TenantRegistry``. When it is not installed, the original in-memory
6``TenantRegistry`` is used as fallback.
7"""
9from __future__ import annotations
11from typing import TYPE_CHECKING, Any
13from lexigram.admin.multitenancy.models import TenantConfig, TenantNotFoundError
14from lexigram.contracts.tenancy.commands import CreateTenantCommand
15from lexigram.logging import get_logger
17if TYPE_CHECKING:
18 from lexigram.contracts.tenancy.protocols import TenantProviderProtocol
19 from lexigram.contracts.tenancy.types import TenantInfo
21logger = get_logger(__name__)
24class TenantProviderRegistry:
25 """Adapter that wraps ``TenantProviderProtocol`` as a ``TenantRegistry``-compatible store.
27 Methods mirror ``TenantRegistry`` (``add``, ``remove``, ``get``,
28 ``get_by_domain``, ``all``, ``exists``). When a ``provider`` is
29 supplied, write operations delegate to the provider and read operations
30 fall through to the provider when the local cache misses.
31 """
33 def __init__(self, provider: TenantProviderProtocol | None = None) -> None:
34 self._provider = provider
35 self._tenants: dict[str, TenantConfig] = {}
36 self._domain_index: dict[str, str] = {}
38 async def add(self, config: TenantConfig) -> None:
39 """Register a tenant.
41 Updates the in-memory cache and, when a provider is available,
42 delegates persistence via ``create_tenant``.
43 """
44 self._tenants[config.tenant_id] = config
45 if config.domain:
46 self._domain_index[config.domain] = config.tenant_id
47 if self._provider is not None:
48 cmd = CreateTenantCommand(
49 slug=config.tenant_id,
50 name=config.name,
51 config={"domain": config.domain} if config.domain else {},
52 metadata=config.metadata,
53 )
54 result = await self._provider.create_tenant(cmd)
55 if result.is_err():
56 logger.warning(
57 "tenant_provider_create_failed",
58 tenant_id=config.tenant_id,
59 error=str(result.unwrap_err()),
60 )
61 logger.debug("Tenant registered: %s (%s)", config.tenant_id, config.name)
63 async def remove(self, tenant_id: str) -> TenantConfig:
64 """Remove a tenant from the registry.
66 Raises:
67 TenantNotFoundError: If the tenant is not registered.
68 """
69 if tenant_id not in self._tenants:
70 raise TenantNotFoundError(tenant_id)
71 config = self._tenants.pop(tenant_id)
72 self._domain_index.pop(config.domain, None)
73 if self._provider is not None:
74 result = await self._provider.deactivate_tenant(tenant_id)
75 if result.is_err():
76 logger.warning(
77 "tenant_provider_deactivate_failed",
78 tenant_id=tenant_id,
79 error=str(result.unwrap_err()),
80 )
81 return config
83 async def get(self, tenant_id: str) -> TenantConfig | None:
84 """Return tenant config by ID, or ``None``.
86 Falls through to the provider when the local cache misses.
87 """
88 cached = self._tenants.get(tenant_id)
89 if cached is not None:
90 return cached
91 if self._provider is not None:
92 info = await self._provider.get_tenant(tenant_id)
93 if info is not None:
94 config = _to_tenant_config(info)
95 self._tenants[config.tenant_id] = config
96 return config
97 return None
99 async def get_or_raise(self, tenant_id: str) -> TenantConfig:
100 """Return tenant config, raising if not found.
102 Raises:
103 TenantNotFoundError: If the tenant is not registered.
104 """
105 config = await self.get(tenant_id)
106 if config is None:
107 raise TenantNotFoundError(tenant_id)
108 return config
110 def get_by_domain(self, domain: str) -> TenantConfig | None:
111 """Return the tenant config for a custom domain, or ``None``.
113 Domain lookups are local-cache-only (the protocol does not expose
114 domain-based queries).
115 """
116 tenant_id = self._domain_index.get(domain)
117 return self._tenants.get(tenant_id) if tenant_id else None
119 async def all(self, *, active_only: bool = False) -> list[TenantConfig]:
120 """Return all registered tenants.
122 When a provider is available, delegates to the provider for the
123 authoritative list; otherwise returns the local cache.
124 """
125 if self._provider is not None:
126 infos = await self._provider.list_tenants(active_only=active_only)
127 tenants = [_to_tenant_config(info) for info in infos]
128 # Refresh the local cache
129 for t in tenants:
130 self._tenants[t.tenant_id] = t
131 if t.domain:
132 self._domain_index[t.domain] = t.tenant_id
133 return tenants
134 tenants = list(self._tenants.values())
135 if active_only:
136 return [t for t in tenants if t.active]
137 return tenants
139 def exists(self, tenant_id: str) -> bool:
140 """Return ``True`` if the tenant is registered.
142 Note: checks the local cache only. The async :meth:`get` should be
143 used when provider fallback is needed.
144 """
145 return tenant_id in self._tenants
148def _to_tenant_config(info: TenantInfo) -> TenantConfig:
149 """Convert a ``TenantInfo`` protocol type to an internal ``TenantConfig``."""
150 return TenantConfig(
151 tenant_id=info.tenant_id,
152 name=info.name,
153 domain=info.config.get("domain", ""),
154 active=info.status.value == "active",
155 metadata=info.metadata,
156 )
159async def resolve_tenant_id(
160 request: Any,
161 *,
162 default: str = "",
163 header: str = "x-tenant-id",
164 cookie: str = "admin_tenant",
165) -> str:
166 """Resolve tenant ID from a request, delegating to ``lexigram-tenancy``
167 resolvers when available.
169 Resolution order mirrors ``get_tenant_id``:
170 1. ``request.state.tenant_id``
171 2. ``X-Tenant-Id`` header
172 3. Cookie named *cookie*
173 4. Subdomain matched against registry
174 5. *default*
175 """
176 # 1. State override
177 state_tenant = getattr(getattr(request, "state", None), "tenant_id", None)
178 if state_tenant:
179 return str(state_tenant)
181 # 2. Header
182 headers = getattr(request, "headers", {})
183 header_val = (
184 headers.get(header, "")
185 if isinstance(headers, dict)
186 else getattr(headers, "get", lambda _k, d="": d)(header, "")
187 )
188 if header_val:
189 return header_val
191 # 3. Cookie
192 cookies = getattr(request, "cookies", {})
193 if isinstance(cookies, dict) and cookie in cookies:
194 return cookies[cookie]
196 # 4. Subdomain
197 url = getattr(request, "url", None)
198 if url:
199 hostname = getattr(url, "hostname", "") or ""
200 parts = hostname.split(".")
201 if len(parts) >= 3:
202 subdomain = parts[0]
203 app_state = getattr(getattr(request, "app", None), "state", None)
204 registry: Any = getattr(app_state, "tenant_registry", None)
205 if registry and hasattr(registry, "get_by_domain"):
206 config = registry.get_by_domain(hostname) or registry.get(subdomain)
207 if config:
208 return config.tenant_id
210 return default
213__all__ = [
214 "TenantProviderRegistry",
215 "resolve_tenant_id",
216]