mirror of
https://github.com/apache/superset.git
synced 2026-05-16 21:35:08 +00:00
Use explicit context API
This commit is contained in:
@@ -30,11 +30,11 @@ Each extension receives its own isolated storage namespace. When Superset loads
|
||||
|
||||
## Storage Tiers
|
||||
|
||||
| Tier | Storage Type | Module | Use Case |
|
||||
|------|--------------|--------|----------|
|
||||
| 1 | Browser storage | `localState`, `sessionState` | UI state, wizard progress, draft forms |
|
||||
| 2 | Server-side cache | `ephemeralState` | Job progress, temporary results |
|
||||
| 3 | Database | `persistentState` | User preferences, extension config (coming soon) |
|
||||
| Tier | Storage Type | Context Property | Use Case |
|
||||
|------|--------------|------------------|----------|
|
||||
| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms |
|
||||
| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results |
|
||||
| 3 | Database | `ctx.storage.persistent` | User preferences, extension config (coming soon) |
|
||||
|
||||
## Tier 1: Local State
|
||||
|
||||
@@ -42,7 +42,7 @@ Browser-based storage that persists on the user's device. Use this for UI state
|
||||
|
||||
### Why Use the API Instead of localStorage Directly?
|
||||
|
||||
You might wonder why extensions should use `localState` instead of directly accessing `window.localStorage`. The managed API provides several benefits:
|
||||
You might wonder why extensions should use `ctx.storage.local` instead of directly accessing `window.localStorage`. The managed API provides several benefits:
|
||||
|
||||
- **Automatic namespacing**: Each extension's data is isolated. Two extensions using the same key name won't collide.
|
||||
- **User isolation**: By default, data is scoped to the current user, preventing data leakage between users on shared devices.
|
||||
@@ -55,16 +55,18 @@ You might wonder why extensions should use `localState` instead of directly acce
|
||||
Data persists across browser sessions until explicitly deleted or the user clears browser storage.
|
||||
|
||||
```typescript
|
||||
import { localState } from '@apache-superset/core/storage';
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Save sidebar state
|
||||
await localState.set('sidebar_collapsed', true);
|
||||
await ctx.storage.local.set('sidebar_collapsed', true);
|
||||
|
||||
// Retrieve it later
|
||||
const isCollapsed = await localState.get('sidebar_collapsed');
|
||||
const isCollapsed = await ctx.storage.local.get('sidebar_collapsed');
|
||||
|
||||
// Remove it
|
||||
await localState.remove('sidebar_collapsed');
|
||||
await ctx.storage.local.remove('sidebar_collapsed');
|
||||
```
|
||||
|
||||
### sessionState
|
||||
@@ -72,26 +74,30 @@ await localState.remove('sidebar_collapsed');
|
||||
Data is cleared when the browser tab is closed. Use for transient state within a single session.
|
||||
|
||||
```typescript
|
||||
import { sessionState } from '@apache-superset/core/storage';
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Save wizard progress (lost when tab closes)
|
||||
await sessionState.set('wizard_step', 3);
|
||||
await sessionState.set('unsaved_form', { name: 'Draft' });
|
||||
await ctx.storage.session.set('wizard_step', 3);
|
||||
await ctx.storage.session.set('unsaved_form', { name: 'Draft' });
|
||||
|
||||
// Retrieve on page reload within same tab
|
||||
const step = await sessionState.get('wizard_step');
|
||||
const step = await ctx.storage.session.get('wizard_step');
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
By default, data is scoped to the current user. Use `shared()` for data that should be accessible to all users on the same device.
|
||||
By default, data is scoped to the current user. Use `shared` for data that should be accessible to all users on the same device.
|
||||
|
||||
```typescript
|
||||
import { localState } from '@apache-superset/core/storage';
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Shared across all users on this device
|
||||
await localState.shared().set('device_id', 'abc-123');
|
||||
const deviceId = await localState.shared().get('device_id');
|
||||
await ctx.storage.local.shared.set('device_id', 'abc-123');
|
||||
const deviceId = await ctx.storage.local.shared.get('device_id');
|
||||
```
|
||||
|
||||
### When to Use Tier 1
|
||||
@@ -114,34 +120,38 @@ Server-side cache storage with automatic TTL expiration. Use for temporary data
|
||||
### Frontend Usage
|
||||
|
||||
```typescript
|
||||
import { ephemeralState } from '@apache-superset/core/storage';
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Store with default TTL (1 hour)
|
||||
await ephemeralState.set('job_progress', { pct: 42, status: 'running' });
|
||||
await ctx.storage.ephemeral.set('job_progress', { pct: 42, status: 'running' });
|
||||
|
||||
// Store with custom TTL (5 minutes)
|
||||
await ephemeralState.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 });
|
||||
await ctx.storage.ephemeral.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 });
|
||||
|
||||
// Retrieve
|
||||
const progress = await ephemeralState.get('job_progress');
|
||||
const progress = await ctx.storage.ephemeral.get('job_progress');
|
||||
|
||||
// Remove
|
||||
await ephemeralState.remove('job_progress');
|
||||
await ctx.storage.ephemeral.remove('job_progress');
|
||||
```
|
||||
|
||||
### Backend Usage
|
||||
|
||||
```python
|
||||
from superset_core.extensions.storage import ephemeral_state
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Store job progress
|
||||
ephemeral_state.set('job_progress', {'pct': 42, 'status': 'running'}, ttl=3600)
|
||||
ctx.storage.ephemeral.set('job_progress', {'pct': 42, 'status': 'running'}, ttl=3600)
|
||||
|
||||
# Retrieve
|
||||
progress = ephemeral_state.get('job_progress')
|
||||
progress = ctx.storage.ephemeral.get('job_progress')
|
||||
|
||||
# Remove
|
||||
ephemeral_state.remove('job_progress')
|
||||
ctx.storage.ephemeral.remove('job_progress')
|
||||
```
|
||||
|
||||
### Shared State
|
||||
@@ -149,17 +159,21 @@ ephemeral_state.remove('job_progress')
|
||||
For data that needs to be visible to all users:
|
||||
|
||||
```typescript
|
||||
import { ephemeralState } from '@apache-superset/core/storage';
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] });
|
||||
const result = await ephemeralState.shared().get('shared_result');
|
||||
const ctx = getContext();
|
||||
|
||||
await ctx.storage.ephemeral.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
const result = await ctx.storage.ephemeral.shared.get('shared_result');
|
||||
```
|
||||
|
||||
```python
|
||||
from superset_core.extensions.storage import ephemeral_state
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ephemeral_state.shared().set('shared_result', {'data': [1, 2, 3]})
|
||||
result = ephemeral_state.shared().get('shared_result')
|
||||
ctx = get_context()
|
||||
|
||||
ctx.storage.ephemeral.shared.set('shared_result', {'data': [1, 2, 3]})
|
||||
result = ctx.storage.ephemeral.shared.get('shared_result')
|
||||
```
|
||||
|
||||
### When to Use Tier 2
|
||||
@@ -184,10 +198,10 @@ Coming soon.
|
||||
|
||||
| Need | Recommended Tier |
|
||||
|------|------------------|
|
||||
| UI state (sidebar collapsed, panel sizes) | `localState` |
|
||||
| Wizard/form progress within a session | `sessionState` |
|
||||
| Background job progress | `ephemeralState` |
|
||||
| Temporary computation cache | `ephemeralState` |
|
||||
| UI state (sidebar collapsed, panel sizes) | `ctx.storage.local` |
|
||||
| Wizard/form progress within a session | `ctx.storage.session` |
|
||||
| Background job progress | `ctx.storage.ephemeral` |
|
||||
| Temporary computation cache | `ctx.storage.ephemeral` |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -196,7 +210,7 @@ All storage keys are automatically namespaced:
|
||||
| Scope | Key Pattern |
|
||||
|-------|-------------|
|
||||
| User-scoped | `superset-ext:{extension_id}:user:{user_id}:{key}` |
|
||||
| Shared | `superset-ext:{extension_id}:{key}` |
|
||||
| Shared | `superset-ext:{extension_id}:shared:{key}` |
|
||||
|
||||
This ensures:
|
||||
- Extensions cannot accidentally access each other's data
|
||||
|
||||
137
superset-core/src/superset_core/extensions/context.py
Normal file
137
superset-core/src/superset_core/extensions/context.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Extension Context API for superset-core extensions.
|
||||
|
||||
Provides access to the current extension's context, including metadata
|
||||
and scoped resources like storage. Extensions call `get_context()` to
|
||||
access their context during execution.
|
||||
|
||||
The context is set by the host (Superset) during extension loading and
|
||||
is only available within extension code.
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
def setup():
|
||||
ctx = get_context()
|
||||
|
||||
# Access extension metadata
|
||||
print(f"Running {ctx.extension.displayName} v{ctx.extension.version}")
|
||||
|
||||
# Access extension-scoped storage
|
||||
ctx.storage.ephemeral.set("lastRun", time.time())
|
||||
data = ctx.storage.ephemeral.get("cachedData")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
|
||||
class StorageAccessor(Protocol):
|
||||
"""Protocol for storage access with user-scoped and shared modes."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a value from storage."""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = 3600) -> None:
|
||||
"""Set a value in storage with optional TTL."""
|
||||
...
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
"""Remove a value from storage."""
|
||||
...
|
||||
|
||||
@property
|
||||
def shared(self) -> "StorageAccessor":
|
||||
"""Shared (cross-user) storage accessor."""
|
||||
...
|
||||
|
||||
|
||||
class ExtensionStorage(Protocol):
|
||||
"""Extension-scoped storage accessor for all available tiers."""
|
||||
|
||||
@property
|
||||
def ephemeral(self) -> StorageAccessor:
|
||||
"""Server-side cache (Redis/Memcached) with TTL."""
|
||||
...
|
||||
|
||||
# Future tiers:
|
||||
# @property
|
||||
# def persistent(self) -> StorageAccessor:
|
||||
# """Database-backed persistent storage."""
|
||||
# ...
|
||||
|
||||
|
||||
class ExtensionContext(Protocol):
|
||||
"""
|
||||
Context object providing extension-specific resources.
|
||||
|
||||
This context is only available during extension execution.
|
||||
Calling `get_context()` outside of an extension will raise an error.
|
||||
"""
|
||||
|
||||
@property
|
||||
def extension(self) -> "Manifest":
|
||||
"""Metadata about the current extension."""
|
||||
...
|
||||
|
||||
@property
|
||||
def storage(self) -> ExtensionStorage:
|
||||
"""Extension-scoped storage across all available tiers."""
|
||||
...
|
||||
|
||||
|
||||
def get_context() -> ExtensionContext:
|
||||
"""
|
||||
Get the current extension's context.
|
||||
|
||||
This function returns the context for the currently executing extension,
|
||||
providing access to extension metadata and scoped resources like storage.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:returns: The current extension's context.
|
||||
:raises RuntimeError: If called outside of an extension context.
|
||||
|
||||
Example:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Access extension metadata
|
||||
print(f"Extension: {ctx.extension.id}")
|
||||
print(f"Version: {ctx.extension.version}")
|
||||
|
||||
# Access extension-scoped storage
|
||||
ctx.storage.ephemeral.set("tempData", data, ttl=3600)
|
||||
value = ctx.storage.ephemeral.get("tempData")
|
||||
|
||||
# Access shared (cross-user) storage
|
||||
ctx.storage.ephemeral.shared.set("globalCounter", count)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"get_context() must be called within an extension context. "
|
||||
"This function is replaced by the host during extension loading."
|
||||
)
|
||||
@@ -37,7 +37,7 @@ Tier 3 - Persistent State (Database) [Future]:
|
||||
|
||||
All tiers follow the same API pattern:
|
||||
- User-scoped by default (private to current user)
|
||||
- shared() accessor for data visible to all users
|
||||
- `shared` accessor for data visible to all users
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.storage import ephemeral_state
|
||||
@@ -47,8 +47,8 @@ Usage:
|
||||
ephemeral_state.set('preference', 'compact', ttl=3600)
|
||||
|
||||
# Shared state (explicit opt-in - visible to all users)
|
||||
ephemeral_state.shared().get('job_progress')
|
||||
ephemeral_state.shared().set('job_progress', {'pct': 42}, ttl=3600)
|
||||
ephemeral_state.shared.get('job_progress')
|
||||
ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600)
|
||||
|
||||
# Future: Persistent state
|
||||
# from superset_core.extensions.storage import persistent_state
|
||||
|
||||
@@ -38,9 +38,9 @@ Usage:
|
||||
ephemeral_state.remove('preference')
|
||||
|
||||
# Shared state (explicit opt-in - visible to all users)
|
||||
ephemeral_state.shared().get('job_progress')
|
||||
ephemeral_state.shared().set('job_progress', {'pct': 42}, ttl=3600)
|
||||
ephemeral_state.shared().remove('job_progress')
|
||||
ephemeral_state.shared.get('job_progress')
|
||||
ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600)
|
||||
ephemeral_state.shared.remove('job_progress')
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
@@ -112,23 +112,24 @@ def remove(key: str) -> None:
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def shared() -> EphemeralStateAccessor:
|
||||
"""
|
||||
Get a shared (global) ephemeral state accessor.
|
||||
class _SharedStub:
|
||||
"""Stub for shared accessor that raises NotImplementedError on any operation."""
|
||||
|
||||
Returns an accessor for state that is shared across all users.
|
||||
Use this for data that needs to be visible to everyone, such as
|
||||
job progress indicators or shared computation results.
|
||||
def get(self, key: str) -> Any:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
WARNING: Data stored via shared() is visible to all users of the extension.
|
||||
Do not store user-specific or sensitive data here.
|
||||
def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
def remove(self, key: str) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
:returns: An accessor for shared ephemeral state.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
#: Shared (global) ephemeral state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
#: Host implementations will replace this during initialization.
|
||||
shared: EphemeralStateAccessor = _SharedStub()
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -24,9 +24,107 @@
|
||||
* including querying extension metadata and monitoring extension lifecycle events.
|
||||
* Extensions can use this API to discover other extensions and react to changes
|
||||
* in the extension ecosystem.
|
||||
*
|
||||
* Extensions can access their own context via `getContext()`, which provides:
|
||||
* - Extension metadata (id, name, version, etc.)
|
||||
* - Extension-scoped storage (localStorage, sessionStorage, ephemeral cache)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { extensions } from '@apache-superset/core';
|
||||
*
|
||||
* // Get the current extension's context
|
||||
* const ctx = extensions.getContext();
|
||||
*
|
||||
* // Access extension metadata
|
||||
* console.log(`Running ${ctx.extension.name} v${ctx.extension.version}`);
|
||||
*
|
||||
* // Access extension-scoped storage
|
||||
* await ctx.storage.local.set('preference', { theme: 'dark' });
|
||||
* await ctx.storage.ephemeral.set('cache', data, { ttl: 300 });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Extension } from '../common';
|
||||
import { StorageTier } from '../storage/types';
|
||||
|
||||
/**
|
||||
* Extension-scoped storage accessor.
|
||||
*
|
||||
* All storage tiers are automatically namespaced to the current extension,
|
||||
* preventing key collisions between extensions.
|
||||
*/
|
||||
export interface ExtensionStorage {
|
||||
/**
|
||||
* Browser localStorage - persists across browser sessions.
|
||||
* Data is scoped to the current extension and user.
|
||||
*/
|
||||
local: StorageTier;
|
||||
|
||||
/**
|
||||
* Browser sessionStorage - cleared when the tab closes.
|
||||
* Data is scoped to the current extension and user.
|
||||
*/
|
||||
session: StorageTier;
|
||||
|
||||
/**
|
||||
* Server-side cache (Redis/Memcached) with TTL.
|
||||
* Data is scoped to the current extension and user.
|
||||
* Use `.shared` for data visible to all users.
|
||||
*/
|
||||
ephemeral: StorageTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context object providing extension-specific resources.
|
||||
*
|
||||
* This context is only available during extension execution.
|
||||
* Calling `getContext()` outside of an extension will throw an error.
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Metadata about the current extension.
|
||||
*/
|
||||
extension: Extension;
|
||||
|
||||
/**
|
||||
* Extension-scoped storage across all tiers.
|
||||
* All keys are automatically namespaced to prevent collisions.
|
||||
*/
|
||||
storage: ExtensionStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current extension's context.
|
||||
*
|
||||
* This function returns the context for the currently executing extension,
|
||||
* providing access to extension metadata and scoped resources like storage.
|
||||
*
|
||||
* @returns The current extension's context.
|
||||
* @throws Error if called outside of an extension context.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { extensions } from '@apache-superset/core';
|
||||
*
|
||||
* const ctx = extensions.getContext();
|
||||
*
|
||||
* // Access extension metadata
|
||||
* console.log(`Extension: ${ctx.extension.id}`);
|
||||
* console.log(`Version: ${ctx.extension.version}`);
|
||||
*
|
||||
* // Access extension-scoped storage
|
||||
* await ctx.storage.local.set('userPref', { sidebar: 'collapsed' });
|
||||
* const pref = await ctx.storage.local.get('userPref');
|
||||
*
|
||||
* // Use ephemeral storage with TTL
|
||||
* await ctx.storage.ephemeral.set('tempData', data, { ttl: 3600 });
|
||||
*
|
||||
* // Access shared (cross-user) storage
|
||||
* await ctx.storage.ephemeral.shared.set('globalCounter', count);
|
||||
* ```
|
||||
*/
|
||||
export declare function getContext(): ExtensionContext;
|
||||
|
||||
/**
|
||||
* Get an extension by its full identifier in the form of: `publisher.name`.
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { JsonValue, StorageAccessor } from './types';
|
||||
* Not guaranteed to survive server restarts.
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use shared() to access state that is visible to all users.
|
||||
* Use `shared` to access state that is visible to all users.
|
||||
*
|
||||
* Cache keys are namespaced automatically:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
@@ -43,9 +43,9 @@ import type { JsonValue, StorageAccessor } from './types';
|
||||
* await ephemeralState.remove('job_progress');
|
||||
*
|
||||
* // Shared state (explicit opt-in - visible to all users)
|
||||
* const result = await ephemeralState.shared().get('shared_result');
|
||||
* await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] });
|
||||
* await ephemeralState.shared().remove('shared_result');
|
||||
* const result = await ephemeralState.shared.get('shared_result');
|
||||
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
* await ephemeralState.shared.remove('shared_result');
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -136,27 +136,25 @@ export declare function set(
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a shared (global) ephemeral state accessor.
|
||||
* Shared (global) ephemeral state accessor.
|
||||
*
|
||||
* Returns an accessor for state that is shared across all users.
|
||||
* Accessor for state that is shared across all users.
|
||||
* Use this for data that needs to be visible to everyone, such as
|
||||
* job progress indicators or shared computation results.
|
||||
*
|
||||
* WARNING: Data stored via shared() is visible to all users of the extension.
|
||||
* WARNING: Data stored via shared is visible to all users of the extension.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @returns An accessor for shared ephemeral state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get shared job progress
|
||||
* const progress = await ephemeralState.shared().get('computation_progress');
|
||||
* const progress = await ephemeralState.shared.get('computation_progress');
|
||||
*
|
||||
* // Update shared job progress
|
||||
* await ephemeralState.shared().set('computation_progress', { pct: 75 });
|
||||
* await ephemeralState.shared.set('computation_progress', { pct: 75 });
|
||||
*
|
||||
* // Clear shared state
|
||||
* await ephemeralState.shared().remove('computation_progress');
|
||||
* await ephemeralState.shared.remove('computation_progress');
|
||||
* ```
|
||||
*/
|
||||
export declare function shared(): EphemeralStateAccessor;
|
||||
export declare const shared: EphemeralStateAccessor;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
*
|
||||
* All tiers follow the same API pattern:
|
||||
* - User-scoped by default (private to current user)
|
||||
* - `shared()` accessor for data visible to all users
|
||||
* - `shared` accessor for data visible to all users
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -48,8 +48,8 @@
|
||||
* const progress = await ephemeralState.get('job_progress');
|
||||
*
|
||||
* // Shared state (visible to all users)
|
||||
* await localState.shared().set('device_id', 'abc-123');
|
||||
* await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] });
|
||||
* await localState.shared.set('device_id', 'abc-123');
|
||||
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
* ```
|
||||
*/
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* devices or synced to the server).
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use shared() to access state visible to all users on the same browser.
|
||||
* Use `shared` to access state visible to all users on the same browser.
|
||||
*
|
||||
* Key patterns:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
@@ -44,8 +44,8 @@
|
||||
* await localState.remove('sidebar_collapsed');
|
||||
*
|
||||
* // Shared state (visible to all users on same browser)
|
||||
* const deviceId = await localState.shared().get('device_id');
|
||||
* await localState.shared().set('device_id', 'abc-123');
|
||||
* const deviceId = await localState.shared.get('device_id');
|
||||
* await localState.shared.set('device_id', 'abc-123');
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -100,24 +100,22 @@ export declare function set(key: string, value: JsonValue): Promise<void>;
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a shared local state accessor.
|
||||
* Shared local state accessor.
|
||||
*
|
||||
* Returns an accessor for state that is shared across all users on the
|
||||
* Accessor for state that is shared across all users on the
|
||||
* same browser/device. Use this for device-specific settings that should
|
||||
* persist regardless of which user is logged in.
|
||||
*
|
||||
* WARNING: Data stored via shared() is visible to all users on this browser.
|
||||
* WARNING: Data stored via shared is visible to all users on this browser.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @returns An accessor for shared local state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get device-specific setting
|
||||
* const deviceId = await localState.shared().get('device_id');
|
||||
* const deviceId = await localState.shared.get('device_id');
|
||||
*
|
||||
* // Set device-specific setting
|
||||
* await localState.shared().set('last_used_printer', 'HP-1234');
|
||||
* await localState.shared.set('last_used_printer', 'HP-1234');
|
||||
* ```
|
||||
*/
|
||||
export declare function shared(): StorageAccessor;
|
||||
export declare const shared: StorageAccessor;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* truly transient UI state that should not persist across sessions.
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use shared() to access state visible to all users on the same browser tab.
|
||||
* Use `shared` to access state visible to all users on the same browser tab.
|
||||
*
|
||||
* Key patterns:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
@@ -44,8 +44,8 @@
|
||||
* await sessionState.remove('wizard_step');
|
||||
*
|
||||
* // Shared state (visible to all users on same tab)
|
||||
* const tempData = await sessionState.shared().get('temp_data');
|
||||
* await sessionState.shared().set('temp_data', { draft: true });
|
||||
* const tempData = await sessionState.shared.get('temp_data');
|
||||
* await sessionState.shared.set('temp_data', { draft: true });
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -102,23 +102,21 @@ export declare function set(key: string, value: JsonValue): Promise<void>;
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a shared session state accessor.
|
||||
* Shared session state accessor.
|
||||
*
|
||||
* Returns an accessor for state that is shared across all users on the
|
||||
* Accessor for state that is shared across all users on the
|
||||
* same browser tab. Data is cleared when the tab/window is closed.
|
||||
*
|
||||
* WARNING: Data stored via shared() is visible to all users on this tab.
|
||||
* WARNING: Data stored via shared is visible to all users on this tab.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @returns An accessor for shared session state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Store temporary shared data
|
||||
* await sessionState.shared().set('temp_computation', result);
|
||||
* await sessionState.shared.set('temp_computation', result);
|
||||
*
|
||||
* // Retrieve temporary shared data
|
||||
* const result = await sessionState.shared().get('temp_computation');
|
||||
* const result = await sessionState.shared.get('temp_computation');
|
||||
* ```
|
||||
*/
|
||||
export declare function shared(): StorageAccessor;
|
||||
export declare const shared: StorageAccessor;
|
||||
|
||||
@@ -72,14 +72,12 @@ export interface StorageAccessor {
|
||||
|
||||
/**
|
||||
* Base interface for a storage tier.
|
||||
* All storage tiers implement this interface with user-scoped default and shared() accessor.
|
||||
* All storage tiers implement this interface with user-scoped default and shared accessor.
|
||||
*/
|
||||
export interface StorageTier extends StorageAccessor {
|
||||
/**
|
||||
* Get a shared storage accessor.
|
||||
* Data stored via shared() is visible to all users.
|
||||
*
|
||||
* @returns An accessor for shared storage.
|
||||
* Shared storage accessor.
|
||||
* Data stored via shared is visible to all users.
|
||||
*/
|
||||
shared(): StorageAccessor;
|
||||
shared: StorageAccessor;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,70 @@
|
||||
import { extensions as extensionsApi } from '@apache-superset/core';
|
||||
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
|
||||
|
||||
type ExtensionContext = extensionsApi.ExtensionContext;
|
||||
|
||||
/**
|
||||
* Current extension context for ambient context pattern.
|
||||
* Set before executing extension code, restored after.
|
||||
*/
|
||||
let currentContext: ExtensionContext | null = null;
|
||||
|
||||
/**
|
||||
* Get the current extension context.
|
||||
* @internal
|
||||
*/
|
||||
export function getCurrentContext(): ExtensionContext | null {
|
||||
return currentContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback with the given extension context.
|
||||
* Saves the previous context and restores it after execution,
|
||||
* supporting nested context switches.
|
||||
*
|
||||
* @param ctx The extension context to use during execution
|
||||
* @param fn The callback to execute
|
||||
* @returns The result of the callback
|
||||
* @internal
|
||||
*/
|
||||
export function useContext<T>(ctx: ExtensionContext, fn: () => T): T {
|
||||
const previousContext = currentContext;
|
||||
currentContext = ctx;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
currentContext = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of useContext for async callbacks.
|
||||
* @internal
|
||||
*/
|
||||
export async function useContextAsync<T>(
|
||||
ctx: ExtensionContext,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousContext = currentContext;
|
||||
currentContext = ctx;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentContext = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
const getContext: typeof extensionsApi.getContext = () => {
|
||||
if (!currentContext) {
|
||||
throw new Error(
|
||||
'getContext() must be called within an extension context. ' +
|
||||
'Ensure this code is being executed during extension loading or ' +
|
||||
'within an extension callback.',
|
||||
);
|
||||
}
|
||||
return currentContext;
|
||||
};
|
||||
|
||||
const getExtension: typeof extensionsApi.getExtension = id =>
|
||||
ExtensionsLoader.getInstance().getExtension(id);
|
||||
|
||||
@@ -26,6 +90,7 @@ const getAllExtensions: typeof extensionsApi.getAllExtensions = () =>
|
||||
ExtensionsLoader.getInstance().getExtensions();
|
||||
|
||||
export const extensions: typeof extensionsApi = {
|
||||
getContext,
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
};
|
||||
|
||||
@@ -121,81 +121,8 @@ export const ephemeralState: typeof storageApi.ephemeralState = {
|
||||
await SupersetClient.delete({ endpoint: url });
|
||||
},
|
||||
|
||||
shared(): StorageTypes.ephemeralState.EphemeralStateAccessor {
|
||||
get shared(): StorageTypes.ephemeralState.EphemeralStateAccessor {
|
||||
const extensionId = getCurrentExtensionId();
|
||||
return new SharedEphemeralStateAccessor(extensionId);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create ephemeral state implementation bound to a specific extension ID.
|
||||
*/
|
||||
export function createBoundEphemeralState(
|
||||
extensionId: string,
|
||||
): typeof storageApi.ephemeralState {
|
||||
class BoundSharedEphemeralAccessor
|
||||
implements StorageTypes.ephemeralState.EphemeralStateAccessor
|
||||
{
|
||||
async get(key: string): Promise<StorageTypes.JsonValue | null> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, true);
|
||||
const response = await SupersetClient.get({ endpoint: url });
|
||||
return response.json?.result ?? null;
|
||||
}
|
||||
|
||||
async set(
|
||||
key: string,
|
||||
value: StorageTypes.JsonValue,
|
||||
options?: StorageTypes.ephemeralState.SetOptions,
|
||||
): Promise<void> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, true);
|
||||
await SupersetClient.put({
|
||||
endpoint: url,
|
||||
body: JSON.stringify({
|
||||
value,
|
||||
ttl: options?.ttl ?? DEFAULT_TTL,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, true);
|
||||
await SupersetClient.delete({ endpoint: url });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_TTL,
|
||||
|
||||
async get(key: string): Promise<StorageTypes.JsonValue | null> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, false);
|
||||
const response = await SupersetClient.get({ endpoint: url });
|
||||
return response.json?.result ?? null;
|
||||
},
|
||||
|
||||
async set(
|
||||
key: string,
|
||||
value: StorageTypes.JsonValue,
|
||||
options?: StorageTypes.ephemeralState.SetOptions,
|
||||
): Promise<void> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, false);
|
||||
await SupersetClient.put({
|
||||
endpoint: url,
|
||||
body: JSON.stringify({
|
||||
value,
|
||||
ttl: options?.ttl ?? DEFAULT_TTL,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const url = buildEphemeralStateUrl(extensionId, key, false);
|
||||
await SupersetClient.delete({ endpoint: url });
|
||||
},
|
||||
|
||||
shared(): StorageTypes.ephemeralState.EphemeralStateAccessor {
|
||||
return new BoundSharedEphemeralAccessor();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,24 +30,9 @@
|
||||
*/
|
||||
|
||||
import { storage as storageApi } from '@apache-superset/core';
|
||||
import { localState, createBoundBrowserStorage } from './localState';
|
||||
import { localState } from './localState';
|
||||
import { sessionState } from './sessionState';
|
||||
import { ephemeralState, createBoundEphemeralState } from './ephemeralState';
|
||||
|
||||
/**
|
||||
* Create a storage instance bound to a specific extension ID.
|
||||
* Used by ExtensionsLoader to provide pre-bound storage to extensions.
|
||||
*
|
||||
* @param extensionId The extension ID to bind storage to.
|
||||
* @returns A storage object with all tiers bound to the extension.
|
||||
*/
|
||||
export function forExtension(extensionId: string): typeof storageApi {
|
||||
return {
|
||||
localState: createBoundBrowserStorage(localStorage, extensionId),
|
||||
sessionState: createBoundBrowserStorage(sessionStorage, extensionId),
|
||||
ephemeralState: createBoundEphemeralState(extensionId),
|
||||
};
|
||||
}
|
||||
import { ephemeralState } from './ephemeralState';
|
||||
|
||||
export const storage: typeof storageApi = {
|
||||
localState,
|
||||
|
||||
@@ -81,64 +81,13 @@ export function createBrowserStorageImpl(
|
||||
storage.removeItem(storageKey);
|
||||
},
|
||||
|
||||
shared(): StorageTypes.StorageAccessor {
|
||||
get shared(): StorageTypes.StorageAccessor {
|
||||
const extensionId = getCurrentExtensionId();
|
||||
return new SharedAccessor(extensionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create browser storage implementation bound to a specific extension ID.
|
||||
*/
|
||||
export function createBoundBrowserStorage(
|
||||
browserStorage: Storage,
|
||||
extensionId: string,
|
||||
): typeof storageApi.localState {
|
||||
class BoundSharedAccessor implements StorageTypes.StorageAccessor {
|
||||
async get(key: string): Promise<StorageTypes.JsonValue | null> {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
const value = browserStorage.getItem(storageKey);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async set(key: string, value: StorageTypes.JsonValue): Promise<void> {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
browserStorage.setItem(storageKey, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
browserStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async get(key: string): Promise<StorageTypes.JsonValue | null> {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
const value = browserStorage.getItem(storageKey);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
|
||||
async set(key: string, value: StorageTypes.JsonValue): Promise<void> {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
browserStorage.setItem(storageKey, JSON.stringify(value));
|
||||
},
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
browserStorage.removeItem(storageKey);
|
||||
},
|
||||
|
||||
shared(): StorageTypes.StorageAccessor {
|
||||
return new BoundSharedAccessor();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local state implementation using localStorage.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { getCurrentContext } from 'src/core/extensions';
|
||||
|
||||
// Key prefix for extension storage
|
||||
export const KEY_PREFIX = 'superset-ext';
|
||||
@@ -30,20 +31,18 @@ export const KEY_PREFIX = 'superset-ext';
|
||||
export const DEFAULT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Get the current extension ID from context.
|
||||
* This is injected by the extension loader when running extension code.
|
||||
* Get the current extension ID from ambient context.
|
||||
* The context is set by ExtensionsLoader when executing extension code.
|
||||
*/
|
||||
export function getCurrentExtensionId(): string {
|
||||
const extensionId = (
|
||||
window as unknown as { __SUPERSET_EXTENSION_ID__?: string }
|
||||
).__SUPERSET_EXTENSION_ID__;
|
||||
if (!extensionId) {
|
||||
const context = getCurrentContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Storage APIs can only be used within an extension context. ' +
|
||||
'Ensure this code is being executed by an extension.',
|
||||
);
|
||||
}
|
||||
return extensionId;
|
||||
return context.extension.id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { forExtension } from 'src/core/storage';
|
||||
import type { common as core, extensions } from '@apache-superset/core';
|
||||
import { storage } from 'src/core/storage';
|
||||
import { useContext } from 'src/core/extensions';
|
||||
import './types';
|
||||
|
||||
type ExtensionContext = extensions.ExtensionContext;
|
||||
|
||||
type Extension = core.Extension;
|
||||
|
||||
/**
|
||||
@@ -139,13 +142,23 @@ class ExtensionsLoader {
|
||||
|
||||
const factory = await container.get('./index');
|
||||
|
||||
// Bind storage to this extension before executing the module.
|
||||
// The extension's imports resolve via webpack externals at load time,
|
||||
// capturing this bound instance.
|
||||
window.superset.storage = forExtension(id);
|
||||
// Create the extension context with ambient storage.
|
||||
// Storage methods will get the extension ID from the ambient context.
|
||||
const context: ExtensionContext = {
|
||||
extension,
|
||||
storage: {
|
||||
local: storage.localState,
|
||||
session: storage.sessionState,
|
||||
ephemeral: storage.ephemeralState,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
// Execute module with ambient context.
|
||||
// Extensions call getContext() to access this.
|
||||
// Context is automatically restored after execution.
|
||||
useContext(context, () => {
|
||||
factory();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { storage as storageTypes } from '@apache-superset/core';
|
||||
import type {
|
||||
authentication,
|
||||
core,
|
||||
@@ -25,6 +24,7 @@ import type {
|
||||
extensions,
|
||||
menus,
|
||||
sqlLab,
|
||||
storage,
|
||||
views,
|
||||
} from 'src/core';
|
||||
|
||||
@@ -38,9 +38,7 @@ declare global {
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
// Use the @apache-superset/core type (what extensions see),
|
||||
// not the host's extended type with forExtension
|
||||
storage: typeof storageTypes;
|
||||
storage: typeof storage;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,6 +245,18 @@ def inject_storage_implementations() -> None:
|
||||
core_ephemeral_state.shared = EphemeralStateImpl.shared
|
||||
|
||||
|
||||
def inject_extension_context() -> None:
|
||||
"""
|
||||
Replace abstract get_context in superset_core.extensions.context
|
||||
with concrete implementation from Superset.
|
||||
"""
|
||||
import superset_core.extensions.context as core_context
|
||||
|
||||
from superset.extensions.context import get_context
|
||||
|
||||
core_context.get_context = get_context
|
||||
|
||||
|
||||
def initialize_core_api_dependencies() -> None:
|
||||
"""
|
||||
Initialize all dependency injections for the superset-core API.
|
||||
@@ -259,3 +271,4 @@ def initialize_core_api_dependencies() -> None:
|
||||
inject_task_implementations()
|
||||
inject_rest_api_implementations()
|
||||
inject_storage_implementations()
|
||||
inject_extension_context()
|
||||
|
||||
@@ -16,75 +16,118 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Extension Context Management - provides ambient context during extension loading.
|
||||
Extension Context Management - provides ambient context for extensions.
|
||||
|
||||
This module provides a thread-local context system that allows decorators to
|
||||
automatically detect whether they are being called in host or extension code
|
||||
during extension loading.
|
||||
This module provides a context system using Python's contextvars that allows
|
||||
extensions to access their context (metadata and scoped resources) via get_context().
|
||||
|
||||
The context is set during extension loading and when extension callbacks are invoked.
|
||||
Uses ContextVar for thread-safe and async-safe context management with automatic
|
||||
save/restore for nested contexts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from threading import local
|
||||
from typing import Any, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Iterator
|
||||
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
# Thread-local storage for extension context
|
||||
_extension_context: local = local()
|
||||
|
||||
class ExtensionStorage:
|
||||
"""Extension storage with all available tiers."""
|
||||
|
||||
@property
|
||||
def ephemeral(self) -> Any:
|
||||
from superset.extensions.storage.ephemeral_state import EphemeralStateImpl
|
||||
|
||||
return EphemeralStateImpl
|
||||
|
||||
|
||||
class ExtensionContext:
|
||||
"""Manages ambient extension context during loading."""
|
||||
|
||||
def __init__(self, manifest: Manifest):
|
||||
self.manifest = manifest
|
||||
|
||||
def __enter__(self) -> "ExtensionContext":
|
||||
if getattr(_extension_context, "current", None) is not None:
|
||||
current_extension = _extension_context.current.manifest.id
|
||||
raise RuntimeError(
|
||||
f"Cannot initialize extension {self.manifest.id} while extension "
|
||||
f"{current_extension} is already being initialized. "
|
||||
f"Nested extension initialization is not supported."
|
||||
)
|
||||
|
||||
_extension_context.current = self
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
# Clear the current context
|
||||
_extension_context.current = None
|
||||
|
||||
|
||||
class ExtensionContextWrapper:
|
||||
"""Wrapper for extension context with extensible properties."""
|
||||
class ConcreteExtensionContext:
|
||||
"""Concrete implementation of ExtensionContext for the host."""
|
||||
|
||||
def __init__(self, manifest: Manifest):
|
||||
self._manifest = manifest
|
||||
self._storage = ExtensionStorage()
|
||||
|
||||
@property
|
||||
def extension(self) -> Manifest:
|
||||
"""Extension metadata (new API)."""
|
||||
return self._manifest
|
||||
|
||||
@property
|
||||
def manifest(self) -> Manifest:
|
||||
"""Get the extension manifest."""
|
||||
"""Extension manifest (for backward compatibility)."""
|
||||
return self._manifest
|
||||
|
||||
# Future: Add other context properties here
|
||||
# @property
|
||||
# def security_context(self) -> SecurityContext: ...
|
||||
# @property
|
||||
# def build_info(self) -> BuildInfo: ...
|
||||
@property
|
||||
def storage(self) -> ExtensionStorage:
|
||||
return self._storage
|
||||
|
||||
|
||||
def get_current_extension_context() -> ExtensionContextWrapper | None:
|
||||
"""Get the currently active extension context wrapper, or None if in host code."""
|
||||
if context := getattr(_extension_context, "current", None):
|
||||
return ExtensionContextWrapper(context.manifest)
|
||||
return None
|
||||
# Context variable for ambient extension context pattern.
|
||||
# Thread-safe and async-safe via Python's contextvars.
|
||||
_current_context: ContextVar[ConcreteExtensionContext | None] = ContextVar(
|
||||
"extension_context", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def extension_context(manifest: Manifest) -> Generator[None, None, None]:
|
||||
"""Context manager for setting extension context during loading."""
|
||||
with ExtensionContext(manifest):
|
||||
def get_context() -> ConcreteExtensionContext:
|
||||
"""
|
||||
Get the current extension's context.
|
||||
|
||||
This is the host implementation that replaces the stub in superset_core.
|
||||
|
||||
:returns: The current extension's context.
|
||||
:raises RuntimeError: If called outside of an extension context.
|
||||
"""
|
||||
context = _current_context.get()
|
||||
if context is None:
|
||||
raise RuntimeError(
|
||||
"get_context() must be called within an extension context. "
|
||||
"Ensure this code is being executed during extension loading or "
|
||||
"within an extension callback."
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def get_current_extension_context() -> ConcreteExtensionContext | None:
|
||||
"""Get the currently active extension context, or None if in host code."""
|
||||
return _current_context.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def use_context(ctx: ConcreteExtensionContext) -> Iterator[None]:
|
||||
"""
|
||||
Context manager to set ambient context for extension execution.
|
||||
|
||||
Used to establish the ambient context before executing extension code.
|
||||
The context is automatically restored after execution, supporting nested
|
||||
context switches.
|
||||
|
||||
:param ctx: ExtensionContext to set as the current context
|
||||
:yields: None
|
||||
"""
|
||||
token = _current_context.set(ctx)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_context.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def extension_context(manifest: Manifest) -> Iterator[ConcreteExtensionContext]:
|
||||
"""
|
||||
Context manager for setting extension context during loading.
|
||||
|
||||
Creates a new ExtensionContext for the given manifest and sets it as
|
||||
the current context. Supports nested contexts via ContextVar tokens.
|
||||
|
||||
:param manifest: The extension manifest
|
||||
:yields: The created ExtensionContext
|
||||
"""
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
with use_context(ctx):
|
||||
yield ctx
|
||||
|
||||
@@ -74,14 +74,13 @@ class SharedEphemeralStateAccessor:
|
||||
Accessor for shared (global) ephemeral state.
|
||||
|
||||
Data stored via this accessor is visible to all users of the extension.
|
||||
Extension ID is resolved lazily on each operation from the current context.
|
||||
"""
|
||||
|
||||
def __init__(self, extension_id: str):
|
||||
self._extension_id = extension_id
|
||||
|
||||
def _build_key(self, key: str) -> str:
|
||||
"""Build a shared (global) cache key."""
|
||||
return _build_cache_key(KEY_PREFIX, self._extension_id, "shared", key)
|
||||
extension_id = _get_extension_id()
|
||||
return _build_cache_key(KEY_PREFIX, extension_id, "shared", key)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""
|
||||
@@ -122,7 +121,7 @@ class EphemeralStateImpl:
|
||||
superset_core.extensions.storage.ephemeral_state.
|
||||
|
||||
By default, all operations are user-scoped (private to the current user).
|
||||
Use shared() to access state that is visible to all users.
|
||||
Use `shared` to access state that is visible to all users.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -169,24 +168,7 @@ class EphemeralStateImpl:
|
||||
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
|
||||
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
|
||||
|
||||
@staticmethod
|
||||
def shared() -> SharedEphemeralStateAccessor:
|
||||
"""
|
||||
Get a shared (global) ephemeral state accessor.
|
||||
|
||||
Returns an accessor for state that is shared across all users.
|
||||
Use this for data that needs to be visible to everyone.
|
||||
|
||||
WARNING: Data stored via shared() is visible to all users of the extension.
|
||||
|
||||
:returns: An accessor for shared ephemeral state.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
return SharedEphemeralStateAccessor(extension_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TTL",
|
||||
"EphemeralStateImpl",
|
||||
"SharedEphemeralStateAccessor",
|
||||
]
|
||||
#: Shared (global) ephemeral state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
shared: SharedEphemeralStateAccessor = SharedEphemeralStateAccessor()
|
||||
|
||||
Reference in New Issue
Block a user