mirror of
https://github.com/apache/superset.git
synced 2026-05-15 04:45:10 +00:00
Compare commits
1 Commits
single-sv
...
fix/extens
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4381fd97ea |
@@ -34,6 +34,7 @@ x-superset-volumes: &superset-volumes
|
||||
- superset_home:/app/superset_home
|
||||
- ./tests:/app/tests
|
||||
- superset_data:/app/data
|
||||
- ./local_extensions:/app/local_extensions
|
||||
x-common-build: &common-build
|
||||
context: .
|
||||
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
|
||||
|
||||
@@ -80,7 +80,9 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 \
|
||||
--extra-files "/app/superset/extensions/.reload_trigger" \
|
||||
--exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*:*/superset/__init__.py"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
@@ -169,7 +169,7 @@ class ExtensionsRestApi(BaseApi):
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/<file>", methods=("GET",))
|
||||
@expose("/<publisher>/<name>/<path:file>", methods=("GET",))
|
||||
def content(self, publisher: str, name: str, file: str) -> Response:
|
||||
"""Get a frontend chunk of an extension.
|
||||
---
|
||||
|
||||
@@ -29,37 +29,113 @@ from flask import Flask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel file Flask watches via --extra-files. Touching it on a real change
|
||||
# triggers a server reload without depending on cwd or the location of any
|
||||
# Python source file.
|
||||
RELOAD_TRIGGER = Path(__file__).resolve().parent / ".reload_trigger"
|
||||
|
||||
# Guard to prevent multiple initializations
|
||||
_watcher_initialized = False
|
||||
_watcher_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_file_handler_class() -> Any:
|
||||
def _get_file_handler_class() -> Any: # noqa: C901
|
||||
"""Get the file handler class, importing watchdog only when needed."""
|
||||
try:
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
import hashlib
|
||||
|
||||
from watchdog.events import (
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
|
||||
class LocalExtensionFileHandler(FileSystemEventHandler):
|
||||
"""Custom file system event handler for LOCAL_EXTENSIONS directories."""
|
||||
"""Custom file system event handler for LOCAL_EXTENSIONS directories.
|
||||
|
||||
Only reacts to genuine content changes (create / modify / move) in the
|
||||
dist directory, verified by comparing a SHA-256 of the file's content.
|
||||
This avoids the Docker VirtioFS / osxfs problem where reading a file
|
||||
generates inotify events that watchdog surfaces as modifications.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# sha256 of last-seen content, keyed by absolute path
|
||||
self._file_hashes: dict[str, str] = {}
|
||||
# Deduplicate: only trigger once per second across all files
|
||||
self._last_trigger: float = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _sha256(path: str) -> str | None:
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
return hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _content_changed(self, path: str) -> bool:
|
||||
"""Return True only when the file's content differs from last seen.
|
||||
|
||||
The first time a path is observed its hash is stored as the baseline
|
||||
and False is returned — that event is a 'first-seen', not a change.
|
||||
Only a subsequent event where the digest differs from the baseline
|
||||
is treated as a genuine content change.
|
||||
"""
|
||||
digest = self._sha256(path)
|
||||
if digest is None:
|
||||
return False
|
||||
old_digest = self._file_hashes.get(path)
|
||||
self._file_hashes[path] = digest
|
||||
if old_digest is None:
|
||||
# First observation — record baseline, do not trigger restart.
|
||||
return False
|
||||
return old_digest != digest
|
||||
|
||||
# ── event handler ─────────────────────────────────────────────────
|
||||
|
||||
def on_any_event(self, event: Any) -> None:
|
||||
"""Handle any file system event in the watched directories."""
|
||||
"""Handle file system events in the watched directories."""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# Only trigger on changes to files in `dist` directory
|
||||
# Only react to true write events; skip access / close / open etc.
|
||||
if not isinstance(
|
||||
event, (FileCreatedEvent, FileModifiedEvent, FileMovedEvent)
|
||||
):
|
||||
return
|
||||
|
||||
# Only care about files inside a `dist` directory
|
||||
src = getattr(event, "src_path", None)
|
||||
if not isinstance(src, str) or "dist" not in Path(src).parts:
|
||||
return
|
||||
|
||||
# Verify the file content actually changed to ignore spurious
|
||||
# inotify events generated by Docker bind-mount reads.
|
||||
if not self._content_changed(src):
|
||||
return
|
||||
|
||||
# Debounce: one restart per second max, regardless of how many
|
||||
# files webpack writes simultaneously.
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if now - self._last_trigger < 1.0:
|
||||
return
|
||||
self._last_trigger = now
|
||||
|
||||
logger.info(
|
||||
"File change detected in LOCAL_EXTENSIONS: %s", event.src_path
|
||||
)
|
||||
|
||||
# Touch superset/__init__.py to trigger Flask's file watcher
|
||||
superset_init = Path("superset/__init__.py")
|
||||
logger.info("Triggering restart by touching %s", superset_init)
|
||||
os.utime(superset_init, (time.time(), time.time()))
|
||||
# Touch the dedicated reload-trigger sentinel file.
|
||||
# Flask watches this via --extra-files; it is never read by Python
|
||||
# so Docker VirtioFS will not generate spurious inotify events on it.
|
||||
logger.info("Triggering restart by touching %s", RELOAD_TRIGGER)
|
||||
os.utime(RELOAD_TRIGGER, (time.time(), time.time()))
|
||||
|
||||
return LocalExtensionFileHandler
|
||||
except ImportError:
|
||||
@@ -130,6 +206,14 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901
|
||||
if not watch_dirs:
|
||||
return
|
||||
|
||||
# Ensure the sentinel exists so os.utime() and Flask's --extra-files watcher
|
||||
# both have a real path to operate on.
|
||||
try:
|
||||
RELOAD_TRIGGER.touch(exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("Could not create reload trigger %s: %s", RELOAD_TRIGGER, e)
|
||||
return
|
||||
|
||||
try:
|
||||
from watchdog.observers import Observer
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from superset.utils.core import check_is_safe_zip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
|
||||
FRONTEND_REGEX = re.compile(r"^frontend/dist/(.+)$")
|
||||
BACKEND_REGEX = re.compile(r"^backend/src/(.+)$")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user