Coverage for src / lexigram / admin / multitenancy / adapter.py: 20%

92 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Adapters that delegate admin multitenancy to lexigram-tenancy. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11from typing import TYPE_CHECKING, Any 

12 

13from lexigram.admin.multitenancy.models import TenantConfig, TenantNotFoundError 

14from lexigram.contracts.tenancy.commands import CreateTenantCommand 

15from lexigram.logging import get_logger 

16 

17if TYPE_CHECKING: 

18 from lexigram.contracts.tenancy.protocols import TenantProviderProtocol 

19 from lexigram.contracts.tenancy.types import TenantInfo 

20 

21logger = get_logger(__name__) 

22 

23 

24class TenantProviderRegistry: 

25 """Adapter that wraps ``TenantProviderProtocol`` as a ``TenantRegistry``-compatible store. 

26 

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 """ 

32 

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] = {} 

37 

38 async def add(self, config: TenantConfig) -> None: 

39 """Register a tenant. 

40 

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) 

62 

63 async def remove(self, tenant_id: str) -> TenantConfig: 

64 """Remove a tenant from the registry. 

65 

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 

82 

83 async def get(self, tenant_id: str) -> TenantConfig | None: 

84 """Return tenant config by ID, or ``None``. 

85 

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 

98 

99 async def get_or_raise(self, tenant_id: str) -> TenantConfig: 

100 """Return tenant config, raising if not found. 

101 

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 

109 

110 def get_by_domain(self, domain: str) -> TenantConfig | None: 

111 """Return the tenant config for a custom domain, or ``None``. 

112 

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 

118 

119 async def all(self, *, active_only: bool = False) -> list[TenantConfig]: 

120 """Return all registered tenants. 

121 

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 

138 

139 def exists(self, tenant_id: str) -> bool: 

140 """Return ``True`` if the tenant is registered. 

141 

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 

146 

147 

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 ) 

157 

158 

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. 

168 

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) 

180 

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 

190 

191 # 3. Cookie 

192 cookies = getattr(request, "cookies", {}) 

193 if isinstance(cookies, dict) and cookie in cookies: 

194 return cookies[cookie] 

195 

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 

209 

210 return default 

211 

212 

213__all__ = [ 

214 "TenantProviderRegistry", 

215 "resolve_tenant_id", 

216]