# 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.
"""Utility functions used across Superset"""
# pylint: disable=too-many-lines
from __future__ import annotations
import _thread
import collections
import errno
import logging
import os
import platform
import re
import signal
import smtplib
import sqlite3
import ssl
import tempfile
import threading
import traceback
import uuid
import warnings
import zlib
from collections.abc import Collection, Iterable, Iterator, Sequence
from contextlib import closing, contextmanager
from dataclasses import dataclass
from datetime import timedelta
from email.mime.application import MIMEApplication
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from enum import Enum, IntEnum
from io import BytesIO
from timeit import default_timer
from types import TracebackType
from typing import (
Any,
Callable,
cast,
NamedTuple,
Optional,
TYPE_CHECKING,
TypedDict,
TypeVar,
)
from urllib.parse import unquote_plus, urlparse
from zipfile import ZipFile
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import markdown as md
import nh3
import pandas as pd
import sqlalchemy as sa
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import Certificate, load_pem_x509_certificate
from flask import current_app as app, g, request
from flask_appbuilder.security.sqla.models import User
from flask_babel import gettext as __
from flask_sqlalchemy import SQLAlchemy
from markupsafe import Markup
from pandas.api.types import infer_dtype
from pandas.core.dtypes.common import is_numeric_dtype
from sqlalchemy import event, exc, inspect, select, Text
from sqlalchemy.dialects.mysql import LONGTEXT, MEDIUMTEXT
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql.type_api import Variant
from sqlalchemy.types import TypeEngine
from typing_extensions import TypeGuard
from superset.constants import (
DEFAULT_USER_AGENT,
EXTRA_FORM_DATA_APPEND_KEYS,
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
NO_TIME_RANGE,
)
from superset.errors import ErrorLevel, SupersetErrorType
from superset.exceptions import (
CertificateException,
SupersetException,
SupersetTimeoutException,
)
from superset.superset_typing import (
AdhocColumn,
AdhocMetric,
AdhocMetricColumn,
Column,
FilterValues,
FlaskResponse,
FormData,
Metric,
)
from superset.utils.backports import StrEnum
from superset.utils.database import get_example_database
from superset.utils.date_parser import parse_human_timedelta
from superset.utils.hashing import hash_from_dict, hash_from_str
from superset.utils.pandas import detect_datetime_format
if TYPE_CHECKING:
from superset.explorables.base import ColumnMetadata, Explorable
from superset.models.core import Database
logging.getLogger("MARKDOWN").setLevel(logging.INFO)
logger = logging.getLogger(__name__)
EMAIL_ATTACHMENT_SUBTYPES: dict[str, str] = {
".pdf": "pdf",
".zip": "zip",
".xlsx": "vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
def build_email_attachment(name: str, body: bytes | str) -> MIMEApplication:
"""
Create an email attachment part with stable filename metadata.
"""
subtype = EMAIL_ATTACHMENT_SUBTYPES.get(os.path.splitext(name)[1].lower())
payload = body.encode("utf-8") if isinstance(body, str) else body
attachment = MIMEApplication(
payload,
_subtype=subtype or "octet-stream",
Name=name,
)
attachment.add_header("Content-Disposition", "attachment", filename=name)
return attachment
DTTM_ALIAS = "__timestamp"
TIME_COMPARISON = "__"
JS_MAX_INTEGER = 9007199254740991 # Largest int Java Script can handle 2^53-1
InputType = TypeVar("InputType") # pylint: disable=invalid-name
ADHOC_FILTERS_REGEX = re.compile("^adhoc_filters")
TYPE_MAPPING = {
re.compile(r"INT", re.IGNORECASE): "integer",
re.compile(r"CHAR|TEXT|VARCHAR", re.IGNORECASE): "string",
re.compile(r"DECIMAL|NUMERIC|FLOAT|DOUBLE", re.IGNORECASE): "floating",
re.compile(r"BOOL", re.IGNORECASE): "boolean",
re.compile(r"DATE|TIME", re.IGNORECASE): "datetime64",
}
METRIC_MAP_TYPE = {
"SUM": "floating",
"AVG": "floating",
"COUNT": "floating",
"COUNT_DISTINCT": "floating",
"MIN": "numeric",
"MAX": "numeric",
"FIRST": "string",
"LAST": "string",
"GROUP_CONCAT": "string",
"ARRAY_AGG": "string",
"STRING_AGG": "string",
"MEDIAN": "floating",
"PERCENTILE": "floating",
"VARIANCE": "floating",
"STDDEV": "floating",
"STDDEV_SAMP": "floating",
"VAR_SAMP": "floating",
}
class AdhocMetricExpressionType(StrEnum):
SIMPLE = "SIMPLE"
SQL = "SQL"
# Aggregates with no safe, universal cross-dialect spelling -- unlike
# SUM/COUNT/AVG/MIN/MAX/COUNT_DISTINCT, whose SQL is generated the same way on
# every engine. Support for these is opt-in per `BaseEngineSpec` (see
# `get_extended_aggregation_func`); used to distinguish a genuinely invalid
# aggregate name from one that is valid but unsupported on the current database,
# for a clearer user-facing error.
EXTENDED_METRIC_AGGREGATES = frozenset({"MEDIAN", "STDDEV_SAMP", "VAR_SAMP"})
class SqlExpressionType(StrEnum):
"""Types of SQL expressions that can be validated."""
COLUMN = "column"
METRIC = "metric"
WHERE = "where"
HAVING = "having"
class AnnotationType(StrEnum):
FORMULA = "FORMULA"
INTERVAL = "INTERVAL"
EVENT = "EVENT"
TIME_SERIES = "TIME_SERIES"
class GenericDataType(IntEnum):
"""
Generic database column type that fits both frontend and backend.
"""
NUMERIC = 0
STRING = 1
TEMPORAL = 2
BOOLEAN = 3
MULTI_VALUE = 4 # array-typed columns (e.g. ClickHouse Array, Postgres ARRAY)
# JSON = 5 # and leaving these as a reminder.
# MAP = 6
# ROW = 7
class DatasourceType(StrEnum):
TABLE = "table"
DATASET = "dataset"
QUERY = "query"
SAVEDQUERY = "saved_query"
VIEW = "view"
SEMANTIC_VIEW = "semantic_view"
class LoggerLevel(StrEnum):
INFO = "info"
WARNING = "warning"
EXCEPTION = "exception"
class HeaderDataType(TypedDict):
notification_format: str
editors: list[int]
notification_type: str
notification_source: str | None
chart_id: int | None
dashboard_id: int | None
slack_channels: list[str] | None
execution_id: str | None
class DatasourceDict(TypedDict):
type: str # todo(hugh): update this to be DatasourceType
id: int | str
class AdhocFilterClause(TypedDict, total=False):
clause: str
expressionType: str
filterOptionName: str | None
comparator: FilterValues | None
operator: str
subject: str
isExtra: bool | None
sqlExpression: str | None
class QueryObjectFilterClause(TypedDict, total=False):
col: Column
op: str # pylint: disable=invalid-name
val: FilterValues | None
grain: str | None
isExtra: bool | None
class ExtraFiltersTimeColumnType(StrEnum):
TIME_COL = "__time_col"
TIME_GRAIN = "__time_grain"
TIME_ORIGIN = "__time_origin"
TIME_RANGE = "__time_range"
class ExtraFiltersReasonType(StrEnum):
NO_TEMPORAL_COLUMN = "no_temporal_column"
COL_NOT_IN_DATASOURCE = "not_in_datasource"
class FilterOperator(StrEnum):
"""
Operators used filter controls
"""
EQUALS = "=="
NOT_EQUALS = "!="
GREATER_THAN = ">"
LESS_THAN = "<"
GREATER_THAN_OR_EQUALS = ">="
LESS_THAN_OR_EQUALS = "<="
LIKE = "LIKE"
NOT_LIKE = "NOT LIKE"
ILIKE = "ILIKE"
NOT_ILIKE = "NOT ILIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
IN = "IN"
NOT_IN = "NOT IN"
IS_TRUE = "IS TRUE"
IS_FALSE = "IS FALSE"
TEMPORAL_RANGE = "TEMPORAL_RANGE"
# Element-level operators for MULTI_VALUE (array) columns
CONTAINS_ANY = "CONTAINS_ANY"
CONTAINS_ALL = "CONTAINS_ALL"
IS_EMPTY = "IS_EMPTY"
IS_NOT_EMPTY = "IS_NOT_EMPTY"
# Length (element-count) comparison operators for array columns
LENGTH_EQUALS = "LENGTH_EQUALS"
LENGTH_GREATER_THAN = "LENGTH_GREATER_THAN"
LENGTH_LESS_THAN = "LENGTH_LESS_THAN"
LENGTH_GREATER_THAN_OR_EQUALS = "LENGTH_GREATER_THAN_OR_EQUALS"
LENGTH_LESS_THAN_OR_EQUALS = "LENGTH_LESS_THAN_OR_EQUALS"
class FilterStringOperators(StrEnum):
EQUALS = ("EQUALS",)
NOT_EQUALS = ("NOT_EQUALS",)
LESS_THAN = ("LESS_THAN",)
GREATER_THAN = ("GREATER_THAN",)
LESS_THAN_OR_EQUAL = ("LESS_THAN_OR_EQUAL",)
GREATER_THAN_OR_EQUAL = ("GREATER_THAN_OR_EQUAL",)
IN = ("IN",)
NOT_IN = ("NOT_IN",)
ILIKE = ("ILIKE",)
LIKE = ("LIKE",)
IS_NOT_NULL = ("IS_NOT_NULL",)
IS_NULL = ("IS_NULL",)
LATEST_PARTITION = ("LATEST_PARTITION",)
IS_TRUE = ("IS_TRUE",)
IS_FALSE = ("IS_FALSE",)
CONTAINS_ANY = ("CONTAINS_ANY",)
CONTAINS_ALL = ("CONTAINS_ALL",)
IS_EMPTY = ("IS_EMPTY",)
IS_NOT_EMPTY = ("IS_NOT_EMPTY",)
LENGTH_EQUALS = ("LENGTH_EQUALS",)
LENGTH_GREATER_THAN = ("LENGTH_GREATER_THAN",)
LENGTH_LESS_THAN = ("LENGTH_LESS_THAN",)
LENGTH_GREATER_THAN_OR_EQUALS = ("LENGTH_GREATER_THAN_OR_EQUALS",)
LENGTH_LESS_THAN_OR_EQUALS = ("LENGTH_LESS_THAN_OR_EQUALS",)
class PostProcessingBoxplotWhiskerType(StrEnum):
"""
Calculate cell contribution to row/column total
"""
TUKEY = "tukey"
MINMAX = "min/max"
PERCENTILE = "percentile"
class PostProcessingContributionOrientation(StrEnum):
"""
Calculate cell contribution to row/column total
"""
ROW = "row"
COLUMN = "column"
class QuerySource(Enum):
"""
The source of a SQL query.
"""
CHART = 0
DASHBOARD = 1
SQL_LAB = 2
class QueryStatus(StrEnum):
"""Enum-type class for query statuses"""
STOPPED = "stopped"
FAILED = "failed"
PENDING = "pending"
RUNNING = "running"
SCHEDULED = "scheduled"
SUCCESS = "success"
FETCHING = "fetching"
TIMED_OUT = "timed_out"
class DashboardStatus(StrEnum):
"""Dashboard status used for frontend filters"""
PUBLISHED = "published"
DRAFT = "draft"
class ReservedUrlParameters(StrEnum):
"""
Reserved URL parameters that are used internally by Superset. These will not be
passed to chart queries, as they control the behavior of the UI.
"""
STANDALONE = "standalone"
EDIT_MODE = "edit"
@staticmethod
def is_standalone_mode() -> bool | None:
standalone_param = request.args.get(ReservedUrlParameters.STANDALONE.value)
standalone: bool | None = bool(
standalone_param and standalone_param != "false" and standalone_param != "0"
)
return standalone
class RowLevelSecurityFilterType(StrEnum):
REGULAR = "Regular"
BASE = "Base"
class ColumnTypeSource(Enum):
GET_TABLE = 1
CURSOR_DESCRIPTION = 2
class ColumnSpec(NamedTuple):
sqla_type: TypeEngine | str
generic_type: GenericDataType
is_dttm: bool
python_date_format: str | None = None
def parse_js_uri_path_item(
item: str | None, unquote: bool = True, eval_undefined: bool = False
) -> str | None:
"""Parse an uri path item made with js.
:param item: an uri path component
:param unquote: Perform unquoting of string using urllib.parse.unquote_plus()
:param eval_undefined: When set to True and item is either 'null' or 'undefined',
assume item is undefined and return None.
:return: Either None, the original item or unquoted item
"""
item = None if eval_undefined and item in ("null", "undefined") else item
return unquote_plus(item) if unquote and item else item
# Matches a safe, opaque token suitable for use as a cookie name. Restricting the
# allowed characters prevents client-controlled input from injecting unexpected
# cookie attributes or control characters.
COOKIE_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
def sanitize_cookie_token(token: str | None) -> str | None:
"""Return the token if it is a valid cookie name, otherwise None.
The export endpoints echo a client-provided ``token`` query parameter back as
a cookie name to signal download completion. Validate it against a strict
allow-list before trusting it.
:param token: the client-provided token value
:return: the token if valid, else None
"""
if token and COOKIE_TOKEN_RE.match(token):
return token
return None
def cast_to_num(value: float | int | str | None) -> float | int | None:
"""Casts a value to an int/float
>>> cast_to_num('1 ')
1.0
>>> cast_to_num(' 2')
2.0
>>> cast_to_num('5')
5
>>> cast_to_num('5.2')
5.2
>>> cast_to_num(10)
10
>>> cast_to_num(10.1)
10.1
>>> cast_to_num(None) is None
True
>>> cast_to_num('this is not a string') is None
True
:param value: value to be converted to numeric representation
:returns: value cast to `int` if value is all digits, `float` if `value` is
decimal value and `None`` if it can't be converted
"""
if value is None:
return None
if isinstance(value, (int, float)):
return value
if value.isdigit():
return int(value)
try:
return float(value)
except ValueError:
return None
def cast_to_boolean(value: Any) -> bool | None:
"""Casts a value to an int/float
>>> cast_to_boolean(1)
True
>>> cast_to_boolean(0)
False
>>> cast_to_boolean(0.5)
True
>>> cast_to_boolean('true')
True
>>> cast_to_boolean('false')
False
>>> cast_to_boolean('False')
False
>>> cast_to_boolean(None)
:param value: value to be converted to boolean representation
:returns: value cast to `bool`. when value is 'true' or value that are not 0
converted into True. Return `None` if value is `None`
"""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() == "true"
return False
def error_msg_from_exception(ex: Exception) -> str:
"""Translate exception into error message
Database have different ways to handle exception. This function attempts
to make sense of the exception object and construct a human readable
sentence.
TODO(bkyryliuk): parse the Presto error message from the connection
created via create_engine.
engine = create_engine('presto://localhost:3506/silver') -
gives an e.message as the str(dict)
presto.connect('localhost', port=3506, catalog='silver') - as a dict.
The latter version is parsed correctly by this function.
"""
msg = ""
if hasattr(ex, "message"):
if isinstance(ex.message, dict):
msg = ex.message.get("message") # type: ignore
elif ex.message:
msg = ex.message
return str(msg) or str(ex)
def markdown(raw: str, markup_wrap: bool | None = False) -> str:
"""Render Markdown to sanitized HTML."""
safe_markdown_tags = {
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"b",
"i",
"strong",
"em",
"tt",
"p",
"br",
"span",
"div",
"blockquote",
"code",
"hr",
"ul",
"ol",
"li",
"dd",
"dt",
"img",
"a",
}
safe_markdown_attrs = {
"img": {"src", "alt", "title"},
"a": {"href", "alt", "title", "target"},
}
safe = md.markdown(
raw or "",
extensions=[
"markdown.extensions.tables",
"markdown.extensions.fenced_code",
"markdown.extensions.codehilite",
],
)
# pylint: disable=no-member
# nh3 preserves supported link attributes and enforces a safe rel value.
safe = nh3.clean(safe, tags=safe_markdown_tags, attributes=safe_markdown_attrs)
if markup_wrap:
safe = Markup(safe)
return safe
def sanitize_svg_content(svg_content: str) -> str:
"""Basic SVG protection - remove obvious XSS vectors, trust admin input otherwise.
Minimal protection approach that removes scripts and javascript: URLs while
preserving all legitimate SVG features. Assumes admin-provided content.
Args:
svg_content: Raw SVG content string
Returns:
str: SVG content with obvious XSS vectors removed
"""
if not svg_content or not svg_content.strip():
return ""
# Minimal protection: remove obvious malicious content, preserve all SVG features
# The closing tag pattern tolerates attributes/whitespace after "script"
# (e.g. ""), which browsers still parse as a valid closer.
content = re.sub(
r"]*>",
"",
svg_content,
flags=re.IGNORECASE | re.DOTALL,
)
# Second pass: an unterminated fragment too.
content = re.sub(r"]*>?", "", content, flags=re.IGNORECASE)
content = re.sub(r"javascript:", "", content, flags=re.IGNORECASE)
content = re.sub(r"data:[^;]*;[^,]*,.*javascript", "", content, flags=re.IGNORECASE)
# Remove event handlers (simple catch-all approach)
content = re.sub(r"\bon\w+\s*=", "", content, flags=re.IGNORECASE)
# Remove other suspicious patterns
content = re.sub(
r"", "", content, flags=re.IGNORECASE | re.DOTALL
)
content = re.sub(
r"", "", content, flags=re.IGNORECASE | re.DOTALL
)
content = re.sub(r"