mirror of
https://github.com/apache/superset.git
synced 2026-07-26 16:42:32 +00:00
Compare commits
14 Commits
codex/fix-
...
fix/resolv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c2c9c48f | ||
|
|
801baf0a0a | ||
|
|
e3d3262e97 | ||
|
|
6c2ccc44bd | ||
|
|
e1ffa53d9b | ||
|
|
4443357f33 | ||
|
|
dd08c4f36c | ||
|
|
4f3a1a5058 | ||
|
|
9d6fbdceff | ||
|
|
8864b7a0a3 | ||
|
|
d4652460ee | ||
|
|
8c37d627aa | ||
|
|
5775bdbe5e | ||
|
|
c65bedf2d7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,6 +57,7 @@ local_config.py
|
||||
/superset_text.yml
|
||||
superset.egg-info/
|
||||
superset/bin/supersetc
|
||||
superset/extensions/.reload_trigger
|
||||
tmp
|
||||
rat-results.txt
|
||||
superset/app/
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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
|
||||
@@ -15,8 +17,6 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Function to determine Python command
|
||||
get_python_command() {
|
||||
if command -v python3 &>/dev/null; then
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -38,7 +38,7 @@ RESET='\033[0m'
|
||||
echo -e "${GREEN}Updating package lists...${RESET}"
|
||||
apt-get update -qq
|
||||
|
||||
echo -e "${GREEN}Installing packages: $@${RESET}"
|
||||
echo -e "${GREEN}Installing packages: $*${RESET}"
|
||||
apt-get install -yqq --no-install-recommends "$@"
|
||||
|
||||
echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}"
|
||||
|
||||
@@ -98,7 +98,9 @@ case "${1}" in
|
||||
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
|
||||
fi
|
||||
|
||||
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
flask run -p $PORT --reload $DEBUGGER_FLAG --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..."
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -54,7 +54,7 @@ dependencies = [
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.2.2, <6.0.0",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-caching>=2.4.1, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
@@ -92,7 +92,7 @@ dependencies = [
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyparsing>=3.0.6, <4",
|
||||
"pyparsing>=3.3.2, <4",
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"pygeohash",
|
||||
@@ -127,7 +127,7 @@ aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.0",
|
||||
"sqlalchemy-bigquery>=1.17.0",
|
||||
"google-cloud-bigquery>=3.42.1",
|
||||
"google-cloud-bigquery>=3.42.2",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
@@ -142,8 +142,8 @@ databricks = [
|
||||
"databricks-sql-connector>=4.2.6, <4.4.0",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
datafusion = ["flightsql-dbapi>=0.2.0, <0.3"]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
|
||||
db2 = ["ibm-db-sa<=0.4.4, >=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
@@ -189,7 +189,7 @@ ocient = [
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
oracle = ["oracledb>=2.0.0, <5"]
|
||||
oracle = ["oracledb>=4.0.2, <5"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.61.0, <2"]
|
||||
@@ -201,7 +201,7 @@ redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
risingwave = ["sqlalchemy-risingwave"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
|
||||
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
|
||||
spark = [
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
@@ -227,7 +227,7 @@ development = [
|
||||
"docker",
|
||||
"flask-testing",
|
||||
"freezegun",
|
||||
"grpcio>=1.81.1",
|
||||
"grpcio>=1.82.1",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
|
||||
@@ -130,7 +130,7 @@ flask-appbuilder==5.2.2
|
||||
# apache-superset-core
|
||||
flask-babel==3.1.0
|
||||
# via flask-appbuilder
|
||||
flask-caching==2.3.1
|
||||
flask-caching==2.4.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-compress==1.24
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -329,7 +329,7 @@ pyopenssl==26.3.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
pyparsing==3.2.3
|
||||
pyparsing==3.3.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pysocks==1.7.1
|
||||
# via urllib3
|
||||
|
||||
@@ -276,7 +276,7 @@ flask-babel==3.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-appbuilder
|
||||
flask-caching==2.3.1
|
||||
flask-caching==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -341,7 +341,7 @@ geopy==2.4.1
|
||||
# apache-superset
|
||||
gevent==26.4.0
|
||||
# via apache-superset
|
||||
google-api-core==2.23.0
|
||||
google-api-core==2.33.0
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
@@ -362,7 +362,7 @@ google-auth-oauthlib==1.2.1
|
||||
# via
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
google-cloud-bigquery==3.42.1
|
||||
google-cloud-bigquery==3.42.2
|
||||
# via
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
@@ -386,7 +386,7 @@ greenlet==3.5.3
|
||||
# sqlalchemy
|
||||
griffelib==2.0.2
|
||||
# via fastmcp-slim
|
||||
grpcio==1.81.1
|
||||
grpcio==1.83.0
|
||||
# via
|
||||
# apache-superset
|
||||
# google-api-core
|
||||
@@ -795,7 +795,7 @@ pyopenssl==26.3.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-auth
|
||||
# shillelagh
|
||||
pyparsing==3.2.3
|
||||
pyparsing==3.3.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -35,7 +35,7 @@ acquire_rat_jar () {
|
||||
wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR"
|
||||
else
|
||||
printf "You do not have curl or wget installed, please install rat manually.\n"
|
||||
exit -1
|
||||
exit 255
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -44,7 +44,7 @@ acquire_rat_jar () {
|
||||
# We failed to download
|
||||
rm "$JAR"
|
||||
printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n"
|
||||
exit -1
|
||||
exit 255
|
||||
fi
|
||||
printf "Done downloading.\n"
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
14
superset-embedded-sdk/package-lock.json
generated
14
superset-embedded-sdk/package-lock.json
generated
@@ -3937,9 +3937,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4114,9 +4114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4134,7 +4134,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
@@ -66,12 +66,6 @@ const StyledTable = styled(Table)<{
|
||||
showRowCount?: boolean;
|
||||
}>`
|
||||
${({ theme, isPaginationSticky, showRowCount }) => `
|
||||
th.ant-column-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
opacity: 0;
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
|
||||
@@ -119,9 +119,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
filter_select_enabled: datasource.filter_select_enabled,
|
||||
fetch_values_predicate: datasource.fetch_values_predicate,
|
||||
schema:
|
||||
datasource.tableSelector?.schema ||
|
||||
datasource.databaseSelector?.schema ||
|
||||
datasource.schema,
|
||||
datasource.tableSelector?.schema ??
|
||||
datasource.databaseSelector?.schema ??
|
||||
datasource.schema ??
|
||||
null,
|
||||
description: datasource.description,
|
||||
main_dttm_col: datasource.main_dttm_col,
|
||||
currency_code_column: datasource.currency_code_column ?? null,
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
@@ -23,9 +23,11 @@ from typing import Callable, Iterable, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
|
||||
|
||||
# Matches only the static asset endpoint: /api/v1/extensions/<publisher>/<name>/<file>
|
||||
# Matches only the static asset endpoint:
|
||||
# /api/v1/extensions/<publisher>/<name>/<path:file>, where the file portion may
|
||||
# contain nested segments (worker / WASM / chunk subfolders).
|
||||
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info) endpoints.
|
||||
_ASSET_PATH_RE = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/[^/]+$")
|
||||
_ASSET_PATH_RE: re.Pattern[str] = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/.+$")
|
||||
|
||||
|
||||
class ExtensionCacheMiddleware:
|
||||
|
||||
@@ -21,7 +21,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -29,37 +28,207 @@ 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 = 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,
|
||||
FileDeletedEvent,
|
||||
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. Populated
|
||||
# from existing files in watched `dist` dirs at startup (see
|
||||
# `prime_baseline`) so that startup-noise inotify events from
|
||||
# Docker VirtioFS reads don't get treated as the first real edit.
|
||||
self._file_hashes: dict[str, str] = {}
|
||||
self._lock: threading.Lock = threading.Lock()
|
||||
# Trailing debounce: schedule a single reload after a quiet
|
||||
# window so simultaneous webpack writes coalesce into one
|
||||
# restart that fires *after* the build settles.
|
||||
self._debounce_seconds: float = 1.0
|
||||
self._pending_timer: threading.Timer | None = None
|
||||
# Monotonically increasing token identifying the most recently
|
||||
# scheduled timer. Guards the timer-already-fired race where
|
||||
# `Timer.cancel()` can't stop a callback that has begun running.
|
||||
self._reload_generation: int = 0
|
||||
|
||||
# ── 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 prime_baseline(self, watch_dirs: set[str]) -> None:
|
||||
"""Pre-populate content hashes for existing files in watched
|
||||
`dist` directories. Called once at watcher startup so a
|
||||
developer's first real edit registers as a content change
|
||||
rather than as the file's 'first observation'."""
|
||||
for root_dir in watch_dirs:
|
||||
root = Path(root_dir)
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if "dist" not in path.parts:
|
||||
continue
|
||||
digest = self._sha256(str(path))
|
||||
if digest is not None:
|
||||
self._file_hashes[str(path)] = digest
|
||||
|
||||
def _content_changed(self, path: str) -> bool:
|
||||
"""Return True when the file's content differs from last seen.
|
||||
|
||||
With `prime_baseline` called at startup, the baseline reflects
|
||||
what was on disk when the watcher started. A first observation
|
||||
that differs (or doesn't exist in baseline) is treated as a
|
||||
genuine change.
|
||||
"""
|
||||
digest = self._sha256(path)
|
||||
if digest is None:
|
||||
return False
|
||||
old_digest = self._file_hashes.get(path)
|
||||
self._file_hashes[path] = digest
|
||||
# New file (not in baseline) is a real change; otherwise compare.
|
||||
return old_digest != digest
|
||||
|
||||
def _trigger_reload(self, source_path: str, generation: int) -> None:
|
||||
"""Touch the reload-trigger sentinel; Flask's --extra-files
|
||||
watcher reloads on its mtime change."""
|
||||
# A newer event may have superseded this timer after it began
|
||||
# running (`cancel()` can't stop an in-flight callback), so only
|
||||
# the most recently scheduled generation is allowed to fire. The
|
||||
# check and the sentinel touch happen inside the same critical
|
||||
# section so a `_schedule_reload` call racing in from another
|
||||
# thread can't bump the generation between the check and the
|
||||
# write and let this stale callback still fire.
|
||||
with self._lock:
|
||||
if generation != self._reload_generation:
|
||||
return
|
||||
logger.info(
|
||||
"File change settled in LOCAL_EXTENSIONS: %s", source_path
|
||||
)
|
||||
logger.info("Triggering restart by touching %s", RELOAD_TRIGGER)
|
||||
try:
|
||||
RELOAD_TRIGGER.touch()
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Failed to touch reload trigger %s: %s", RELOAD_TRIGGER, e
|
||||
)
|
||||
|
||||
def _schedule_reload(self, source_path: str) -> None:
|
||||
"""Trailing-debounce: cancel any pending reload and schedule a
|
||||
new one for `_debounce_seconds` from now. Each new event resets
|
||||
the timer, so the reload fires only after a quiet window."""
|
||||
with self._lock:
|
||||
if self._pending_timer is not None:
|
||||
self._pending_timer.cancel()
|
||||
self._reload_generation += 1
|
||||
timer = threading.Timer(
|
||||
self._debounce_seconds,
|
||||
self._trigger_reload,
|
||||
args=(source_path, self._reload_generation),
|
||||
)
|
||||
timer.daemon = True
|
||||
self._pending_timer = timer
|
||||
timer.start()
|
||||
|
||||
def _handle_moved(self, event: Any) -> None:
|
||||
"""Moves into/out of `dist` are explicit signals — trigger
|
||||
regardless of content match (the source may already be gone
|
||||
or the destination may not have a meaningful hash yet).
|
||||
Atomic-build workflows rename tmp -> dist (dest in dist),
|
||||
while removing an artifact renames dist -> elsewhere (src in
|
||||
dist); either side touching `dist` is a real signal."""
|
||||
dest = getattr(event, "dest_path", None)
|
||||
src = getattr(event, "src_path", None)
|
||||
# The file no longer lives at the source path; evict its
|
||||
# hash entry so the index only tracks paths that exist.
|
||||
if isinstance(src, str):
|
||||
self._file_hashes.pop(src, None)
|
||||
dist_side = next(
|
||||
(
|
||||
p
|
||||
for p in (dest, src)
|
||||
if isinstance(p, str) and "dist" in Path(p).parts
|
||||
),
|
||||
None,
|
||||
)
|
||||
if dist_side is not None:
|
||||
self._schedule_reload(dist_side)
|
||||
|
||||
# ── 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
|
||||
src = getattr(event, "src_path", None)
|
||||
if not isinstance(src, str) or "dist" not in Path(src).parts:
|
||||
# Deletions don't trigger a reload (webpack clean steps delete
|
||||
# old chunks right before writing new ones, which trigger via
|
||||
# the subsequent create/modify), but the stale hash entry must
|
||||
# be evicted so `_file_hashes` doesn't grow without bound as
|
||||
# hashed chunk filenames churn across rebuilds.
|
||||
if isinstance(event, FileDeletedEvent):
|
||||
src = getattr(event, "src_path", None)
|
||||
if isinstance(src, str):
|
||||
self._file_hashes.pop(src, None)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"File change detected in LOCAL_EXTENSIONS: %s", event.src_path
|
||||
)
|
||||
# Only react to true write events; skip access / close / open etc.
|
||||
if not isinstance(
|
||||
event, (FileCreatedEvent, FileModifiedEvent, FileMovedEvent)
|
||||
):
|
||||
return
|
||||
|
||||
# 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()))
|
||||
if isinstance(event, FileMovedEvent):
|
||||
self._handle_moved(event)
|
||||
return
|
||||
|
||||
# For Create/Modify events watchdog only sets src_path.
|
||||
target = getattr(event, "src_path", None)
|
||||
if not isinstance(target, str):
|
||||
return
|
||||
|
||||
# Only care about paths inside a `dist` directory.
|
||||
if "dist" not in Path(target).parts:
|
||||
return
|
||||
|
||||
# For Create/Modify, verify the content actually changed to
|
||||
# ignore spurious inotify events generated by Docker bind-mount
|
||||
# reads.
|
||||
if not self._content_changed(target):
|
||||
return
|
||||
|
||||
self._schedule_reload(target)
|
||||
|
||||
return LocalExtensionFileHandler
|
||||
except ImportError:
|
||||
@@ -130,11 +299,23 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901
|
||||
if not watch_dirs:
|
||||
return
|
||||
|
||||
# Ensure the sentinel exists so touch() 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
|
||||
|
||||
# Set up and start the file watcher
|
||||
event_handler = handler_class()
|
||||
# Pre-populate baseline hashes from existing dist files so the
|
||||
# developer's first real edit isn't silently dropped as a "first
|
||||
# observation".
|
||||
event_handler.prime_baseline(watch_dirs)
|
||||
observer = Observer()
|
||||
|
||||
for watch_dir in watch_dirs:
|
||||
|
||||
@@ -35,12 +35,17 @@ from superset.utils.core import check_is_safe_zip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
|
||||
# Accept nested paths inside frontend/dist so extensions can serve
|
||||
# worker / WASM / chunk subfolders. Reject any entry whose path contains "..",
|
||||
# conservatively excluding parent traversal segments so a crafted entry name
|
||||
# cannot escape the bundle directory (defense in depth; check_is_safe_zip runs
|
||||
# first).
|
||||
FRONTEND_REGEX: re.Pattern[str] = re.compile(r"^frontend/dist/(?!.*\.\.)(.+)$")
|
||||
# Reject any entry whose path contains "..", conservatively excluding parent
|
||||
# traversal segments along with the (in practice nonexistent) case of a module
|
||||
# path embedding consecutive dots, so a crafted entry name cannot produce a
|
||||
# traversal-style module path (defense in depth; check_is_safe_zip runs first).
|
||||
BACKEND_REGEX = re.compile(r"^backend/src/(?!.*\.\.)(.+)$")
|
||||
BACKEND_REGEX: re.Pattern[str] = re.compile(r"^backend/src/(?!.*\.\.)(.+)$")
|
||||
|
||||
|
||||
class InMemoryLoader(importlib.abc.Loader):
|
||||
|
||||
@@ -64,6 +64,21 @@ def test_asset_path_is_intercepted() -> None:
|
||||
assert "Cookie" not in vary
|
||||
|
||||
|
||||
def test_nested_asset_path_is_intercepted() -> None:
|
||||
headers: ResponseHeaders = call_middleware(
|
||||
"/api/v1/extensions/acme/my-ext/workers/nested/chunk.wasm",
|
||||
[("Vary", "Accept-Encoding, Cookie")],
|
||||
)
|
||||
vary: str = dict(headers).get("Vary", "")
|
||||
assert "Cookie" not in vary
|
||||
|
||||
|
||||
def test_get_endpoint_with_trailing_slash_is_not_intercepted() -> None:
|
||||
upstream: ResponseHeaders = [("Vary", "Accept-Encoding, Cookie")]
|
||||
headers = call_middleware("/api/v1/extensions/acme/my-ext/", upstream)
|
||||
assert headers == upstream
|
||||
|
||||
|
||||
def test_list_endpoint_is_not_intercepted() -> None:
|
||||
upstream = [("Vary", "Accept-Encoding, Cookie")]
|
||||
headers = call_middleware("/api/v1/extensions/", upstream)
|
||||
|
||||
59
tests/unit_tests/extensions/test_local_extensions_watcher.py
Normal file
59
tests/unit_tests/extensions/test_local_extensions_watcher.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
from typing import Any
|
||||
|
||||
from watchdog.events import FileDeletedEvent, FileMovedEvent
|
||||
|
||||
from superset.extensions.local_extensions_watcher import _get_file_handler_class
|
||||
|
||||
|
||||
def _noop_schedule_reload(_path: str) -> None:
|
||||
"""Stand in for a real debounce timer in unit tests."""
|
||||
|
||||
|
||||
def make_handler() -> Any:
|
||||
handler_class = _get_file_handler_class()
|
||||
handler = handler_class()
|
||||
# Avoid spinning up real debounce timers in unit tests.
|
||||
handler._schedule_reload = _noop_schedule_reload
|
||||
return handler
|
||||
|
||||
|
||||
def test_delete_evicts_hash_entry() -> None:
|
||||
handler = make_handler()
|
||||
handler._file_hashes["/ext/dist/old-chunk.abc123.js"] = "digest"
|
||||
|
||||
handler.on_any_event(FileDeletedEvent("/ext/dist/old-chunk.abc123.js"))
|
||||
|
||||
assert "/ext/dist/old-chunk.abc123.js" not in handler._file_hashes
|
||||
|
||||
|
||||
def test_delete_of_untracked_path_is_a_noop() -> None:
|
||||
handler = make_handler()
|
||||
|
||||
handler.on_any_event(FileDeletedEvent("/ext/dist/never-seen.js"))
|
||||
|
||||
assert handler._file_hashes == {}
|
||||
|
||||
|
||||
def test_move_out_of_dist_evicts_source_hash_entry() -> None:
|
||||
handler = make_handler()
|
||||
handler._file_hashes["/ext/dist/chunk.js"] = "digest"
|
||||
|
||||
handler.on_any_event(FileMovedEvent("/ext/dist/chunk.js", "/ext/tmp/chunk.js"))
|
||||
|
||||
assert "/ext/dist/chunk.js" not in handler._file_hashes
|
||||
Reference in New Issue
Block a user