/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/util/utils.ts
693 строки
21 KB
Lukas Masuch
Apply additional protobuf cleanups (#13767)
02 фев 2026, 23:58
Не верифицирован
02 фев 2026, 23:58
fa33900
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed 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. */ import decamelize from "decamelize" import { get } from "lodash-es" import xxhash from "xxhashjs" import { Alert as AlertProto, ChatInput as ChatInputProto, Element, LabelVisibility as LabelVisibilityProto, Skeleton as SkeletonProto, } from "@streamlit/protobuf" import { isNullOrUndefined, notNullOrUndefined } from "@streamlit/utils" import { assertNever } from "./assertNever" // This prefix should be in sync with the value on the python side: export const GENERATED_ELEMENT_ID_PREFIX = "$$ID" /** * Wraps a function to allow it to be called, at most, once per interval * (specified in milliseconds). If the wrapper function is called N times * within that interval, only the Nth call will go through. The function * will only be called after the full interval has elapsed since the last * call. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. export function debounce(delay: number, fn: any): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. let timerId: any // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. return (...args: any[]) => { if (timerId) { clearTimeout(timerId) } timerId = setTimeout(() => { fn(...args) timerId = null }, delay) } } /** * Embed query param values, which can be set in ?embed={value}, all should be lowercase */ export const EMBED_QUERY_PARAM_KEY = "embed" export const EMBED_OPTIONS_QUERY_PARAM_KEY = "embed_options" export const EMBED_SHOW_TOOLBAR = "show_toolbar" export const EMBED_SHOW_PADDING = "show_padding" export const EMBED_DISABLE_SCROLLING = "disable_scrolling" export const EMBED_LIGHT_THEME = "light_theme" export const EMBED_DARK_THEME = "dark_theme" export const EMBED_TRUE = "true" export const EMBED_HIDE_LOADING_SCREEN = "hide_loading_screen" export const EMBED_SHOW_LOADING_SCREEN_V1 = "show_loading_screen_v1" export const EMBED_SHOW_LOADING_SCREEN_V2 = "show_loading_screen_v2" export const EMBED_QUERY_PARAM_VALUES = [ EMBED_SHOW_TOOLBAR, EMBED_SHOW_PADDING, EMBED_DISABLE_SCROLLING, EMBED_LIGHT_THEME, EMBED_DARK_THEME, EMBED_HIDE_LOADING_SCREEN, EMBED_SHOW_LOADING_SCREEN_V1, EMBED_SHOW_LOADING_SCREEN_V2, EMBED_TRUE, ] export enum LoadingScreenType { NONE, V1, V2, } /** * Returns list of defined in EMBED_QUERY_PARAM_VALUES url params of given key * (EMBED_QUERY_PARAM_KEY, EMBED_OPTIONS_QUERY_PARAM_KEY). Is case insensitive. */ export function getEmbedUrlParams(embedKey: string): Set<string> { const embedUrlParams = new Set<string>() const urlParams = new URLSearchParams(window.location.search) urlParams.forEach((paramValue, paramKey) => { paramKey = paramKey.toString().toLowerCase() paramValue = paramValue.toString().toLowerCase() if ( paramKey === embedKey && EMBED_QUERY_PARAM_VALUES.includes(paramValue) ) { embedUrlParams.add(paramValue) } }) return embedUrlParams } /** * Returns "embed" and "embed_options" query param options in the url. Returns empty string if not embedded. * Example: * returns "embed=true&embed_options=show_loading_screen_v2" if the url is * http://localhost:3000/test?embed=true&embed_options=show_loading_screen_v2 */ export function preserveEmbedQueryParams(): string { if (!isEmbed()) { return "" } const embedOptionsValues = new URLSearchParams( window.location.search ).getAll(EMBED_OPTIONS_QUERY_PARAM_KEY) // instantiate multiple key values with an array of string pairs // https://stackoverflow.com/questions/72571132/urlsearchparams-with-multiple-values const embedUrlMap: string[][] = [] embedUrlMap.push([EMBED_QUERY_PARAM_KEY, EMBED_TRUE]) embedOptionsValues.forEach((embedValue: string) => { embedUrlMap.push([EMBED_OPTIONS_QUERY_PARAM_KEY, embedValue]) }) return new URLSearchParams(embedUrlMap).toString() } /** * Builds a query string by combining an optional override with preserved embed params. * Used during page navigation to merge user query params with embed options. */ export function getQueryString( queryStringOverride: string | undefined, preservedQueryParams: string ): string { if (queryStringOverride !== undefined) { if (preservedQueryParams) { return queryStringOverride ? `${preservedQueryParams}&${queryStringOverride}` : preservedQueryParams } return queryStringOverride } return preservedQueryParams } /** * Returns true if the URL parameters contain ?embed=true (case insensitive). */ export function isEmbed(): boolean { return getEmbedUrlParams(EMBED_QUERY_PARAM_KEY).has(EMBED_TRUE) } /** * Returns true if the URL parameters contain ?embed=true&embed_options=show_toolbar (case insensitive). */ export function isToolbarDisplayed(): boolean { return ( isEmbed() && getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY).has(EMBED_SHOW_TOOLBAR) ) } /** * Returns true if the URL parameters contain ?embed=true&embed_options=disable_scrolling (case insensitive). */ export function isScrollingHidden(): boolean { return ( isEmbed() && getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY).has( EMBED_DISABLE_SCROLLING ) ) } /** * Returns true if the URL parameters contain ?embed=true&embed_options=show_padding (case insensitive). */ export function isPaddingDisplayed(): boolean { return ( isEmbed() && getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY).has(EMBED_SHOW_PADDING) ) } /** * Returns true if the URL parameters contain ?embed_options=light_theme (case insensitive). */ export function isLightThemeInQueryParams(): boolean { // NOTE: We don't check for ?embed=true here, because we want to allow display without any // other embed options (for example in our e2e tests). return getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY).has( EMBED_LIGHT_THEME ) } /** * Returns true if the URL parameters contain ?embed_options=dark_theme (case insensitive). */ export function isDarkThemeInQueryParams(): boolean { // NOTE: We don't check for ?embed=true here, because we want to allow display without any // other embed options (for example in our e2e tests). return getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY).has(EMBED_DARK_THEME) } /** * Returns true if the parent parameter indicates that we're in an iframe. */ export function isInChildFrame(): boolean { return window.parent !== window } /** * Returns the URL of the app without query parameters, handling both embedded and non-embedded cases. * If the app is embedded in an iframe, it attempts to get the parent frame's URL. */ export function getUrl(): string { let url: string try { // Try to access top location if we're in an iframe if (isInChildFrame() && window.top) { url = window.top.location.href } else { url = document.location.href } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // CSP error might occur when trying to access parent frame url = document.location.href } // Remove query parameters and anchor from the URL const urlObj = new URL(url) urlObj.search = "" urlObj.hash = "" return urlObj.toString() } /** * Returns date in "YYYY-MM-DD-HH-MM-SS" format to be used for screencast recording file name. */ export function getScreencastTimestamp(): string { const date = new Date() const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") const hours = String(date.getHours()).padStart(2, "0") const minutes = String(date.getMinutes()).padStart(2, "0") const seconds = String(date.getSeconds()).padStart(2, "0") return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}` } /** * Returns the timezone from the browser's Intl API. */ export function getTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone } /** * Returns the timezone offset in minutes from the browser's Date API. */ export function getTimezoneOffset(): number { return new Date().getTimezoneOffset() } /** * Returns the browser's locale language setting. */ export function getLocaleLanguage(): string { return navigator.language } /** * Returns a string with the type of loading screen to use while the app is * waiting for the backend to send displayable protos. */ export function getLoadingScreenType(): LoadingScreenType { const params = getEmbedUrlParams(EMBED_OPTIONS_QUERY_PARAM_KEY) return params.has(EMBED_HIDE_LOADING_SCREEN) ? LoadingScreenType.NONE : params.has(EMBED_SHOW_LOADING_SCREEN_V1) ? LoadingScreenType.V1 : LoadingScreenType.V2 } /** Return an info Element protobuf with the given text. */ export function makeElementWithInfoText(text: string): Element { return new Element({ alert: { body: text, format: AlertProto.Format.INFO, }, }) } /** Return an error Element protobuf with the given text. */ export function makeElementWithErrorText(text: string): Element { return new Element({ alert: { body: text, format: AlertProto.Format.ERROR, }, }) } /** Return a special internal-only Element showing an app "skeleton". */ export function makeAppSkeletonElement(): Element { return new Element({ skeleton: { style: SkeletonProto.SkeletonStyle.APP }, }) } /** * A helper function to hash a string using xxHash32 algorithm. * Seed used: 0xDEADBEEF */ export function hashString(s: string): string { return xxhash.h32(s, 0xdeadbeef).toString(16) } /** * Coerces a possibly-null value into a non-null value, throwing an error * if the value is null or undefined. */ export function requireNonNull<T>(obj: T | null | undefined): T { if (isNullOrUndefined(obj)) { throw new Error("value is null") } return obj } /** * A type predicate that is true if the given value is not undefined. */ export function notUndefined<T>(value: T | undefined): value is T { return value !== undefined } /** * A promise that would be resolved after certain time * @param ms number */ export function timeout(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Tests if the app is running from a Mac */ export function isFromMac(): boolean { return /Mac/i.test(navigator.platform) } /** * Tests if the app is running from a Windows */ export function isFromWindows(): boolean { return /^Win/i.test(navigator.platform) } /** * Sets cookie value */ export function setCookie( name: string, value?: string, expiration?: Date ): void { const expirationDate = value ? expiration : new Date() const expirationStr: string = expirationDate ? `expires=${expirationDate.toUTCString()};` : "" document.cookie = `${name}=${value};${expirationStr}path=/` } export function isValidElementId( elementId: string | undefined | null ): boolean { if (!elementId) { return false } return ( elementId.startsWith(GENERATED_ELEMENT_ID_PREFIX) && // There must be at least 3 parts: $$ID-<hash>-<userKey> elementId.split("-").length >= 3 ) } /** * If the element has a valid ID, returns it. Otherwise, returns undefined. */ export function getElementId(element: Element): string | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const elementId = get(element as any, [requireNonNull(element.type), "id"]) if (elementId && isValidElementId(elementId)) { // We only care about valid element IDs (with the correct prefix) return elementId } return undefined } /** True if the given form ID is non-null and non-empty. */ export function isValidFormId(formId?: string): formId is string { return notNullOrUndefined(formId) && formId.length > 0 } /** True if the given widget element is part of a form. */ export function isInForm(widget: { formId?: string }): boolean { return isValidFormId(widget.formId) } /** * Determines the appropriate placeholder text for select-type widgets. * Handles both single-select and multi-select cases with appropriate pluralization. * * @param placeholder - The custom placeholder provided by the user (empty string means use defaults) * @param options - Array of available options * @param acceptNewOptions - Whether the widget accepts new options * @param isMultiSelect - Whether this is for a multi-select widget (affects pluralization) * @returns Object containing the placeholder text and whether the widget should be disabled */ export function getSelectPlaceholder( placeholder: string, options: readonly string[], acceptNewOptions: boolean, isMultiSelect = false ): { placeholder: string; shouldDisable: boolean } { let shouldDisable = false // If custom placeholder is provided (not empty string), use it as-is if (placeholder !== "") { return { placeholder, shouldDisable } } // Determine appropriate default placeholder based on widget state if (options.length === 0) { if (!acceptNewOptions) { placeholder = "No options to select" // When a user cannot add new options and there are no options to select from, we disable the widget shouldDisable = true } else { placeholder = isMultiSelect ? "Add options" : "Add an option" } } else { // For non-empty options, set appropriate default placeholder if (acceptNewOptions) { placeholder = isMultiSelect ? "Choose or add options" : "Choose or add an option" } else { placeholder = isMultiSelect ? "Choose options" : "Choose an option" } } return { placeholder, shouldDisable } } export enum LabelVisibilityOptions { Visible, Hidden, Collapsed, } export function labelVisibilityProtoValueToEnum( value: LabelVisibilityProto.LabelVisibilityOptions | null | undefined ): LabelVisibilityOptions { switch (value) { case LabelVisibilityProto.LabelVisibilityOptions.VISIBLE: return LabelVisibilityOptions.Visible case LabelVisibilityProto.LabelVisibilityOptions.HIDDEN: return LabelVisibilityOptions.Hidden case LabelVisibilityProto.LabelVisibilityOptions.COLLAPSED: return LabelVisibilityOptions.Collapsed default: return LabelVisibilityOptions.Visible } } export enum AcceptFileValue { None, Single, Multiple, Directory, } export function chatInputAcceptFileProtoValueToEnum( value: ChatInputProto.AcceptFile ): AcceptFileValue { switch (value) { case ChatInputProto.AcceptFile.NONE: return AcceptFileValue.None case ChatInputProto.AcceptFile.SINGLE: return AcceptFileValue.Single case ChatInputProto.AcceptFile.MULTIPLE: return AcceptFileValue.Multiple case ChatInputProto.AcceptFile.DIRECTORY: return AcceptFileValue.Directory default: assertNever(value) return AcceptFileValue.None } } /** * Looks for an IFrame with given className inside given querySet */ export function findAnIFrameWithClassName( qs: NodeListOf<HTMLIFrameElement> | HTMLCollectionOf<HTMLIFrameElement>, className: string ): HTMLIFrameElement | null { for (let i = 0; i < qs.length; i++) { const cd = qs[i].contentDocument if (cd && cd.getElementsByClassName(className).length > 0) { return qs[i] } } return null } /** * Returns True if IFrame can be accessed otherwise returns False */ export function canAccessIFrame(iframe: HTMLIFrameElement): boolean { try { if (iframe.contentWindow === null) return false const doc = iframe.contentDocument || iframe.contentWindow.document const html = doc.body.innerHTML return html !== null && html !== "" // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { return false } } /** * Tries to get an IFrame in which Streamlit app is embedded on Cloud deployments. * It assumes iframe has title="streamlitApp", iterates over IFrames, * and looks which IFrame contains div with stAppId value, otherwise returns first found iFrame or null. */ export function getIFrameEnclosingApp( embeddingId: string ): HTMLIFrameElement | null { if (!isInChildFrame()) { return null } const embeddingIdClassName = getEmbeddingIdClassName(embeddingId) const qsStreamlitAppStr = 'iframe[title="streamlitApp"]' let qs: NodeListOf<HTMLIFrameElement> = window.document.querySelectorAll(qsStreamlitAppStr) let foundIFrame = findAnIFrameWithClassName(qs, embeddingIdClassName) if (foundIFrame && !canAccessIFrame(foundIFrame)) { return null } if (foundIFrame) { return foundIFrame } if (window.parent) { qs = window.parent.document.querySelectorAll(qsStreamlitAppStr) } foundIFrame = findAnIFrameWithClassName(qs, embeddingIdClassName) if (foundIFrame && !canAccessIFrame(foundIFrame)) { return null } if (foundIFrame) { return foundIFrame } let htmlCollection = window.document.getElementsByTagName("iframe") foundIFrame = findAnIFrameWithClassName(htmlCollection, embeddingIdClassName) if (foundIFrame && !canAccessIFrame(foundIFrame)) { return null } if (foundIFrame) { return foundIFrame } if (window.parent) { htmlCollection = window.parent.document.getElementsByTagName("iframe") } foundIFrame = findAnIFrameWithClassName(htmlCollection, embeddingIdClassName) if (foundIFrame && !canAccessIFrame(foundIFrame)) { return null } return foundIFrame } /** * Returns UID generated based on current date and Math.random module */ export function generateUID(): string { return ( Math.floor(Date.now() / 1000).toString(36) + Math.random().toString(36).slice(-6) ) } /** * Returns stAppEmbeddingId-${this.embeddingId} string, * which is used as class to detect iFrame when printing */ export function getEmbeddingIdClassName(embeddingId: string): string { return `stAppEmbeddingId-${embeddingId}` } export function extractPageNameFromPathName( pathname: string, basePath: string ): string { // We'd prefer to write something like // // ``` // replace( // new RegExp(`^/${basePath}/?`), // "" // ) // ``` // // below, but that doesn't work because basePath may contain unescaped // regex special-characters. This is why we're stuck with the // weird-looking triple `replace()`. return decodeURIComponent( pathname .replace(basePath, "") .replace(new RegExp("^/?"), "") .replace(new RegExp("/$"), "") ) } /** * Converts object keys from camelCase to snake_case, applied recursively to nested objects and arrays. * Keys containing dots are replaced with underscores. The conversion preserves consecutive uppercase letters. * * @param obj - The input object with keys to be converted. Can include nested objects and arrays. * @returns A new object with all keys in snake_case, maintaining the original structure and values. * * @example * keysToSnakeCase({ * userId: 1, * user.Info: { firstName: "John", lastName: "Doe" }, * userActivities: [{ loginTime: "10AM", logoutTime: "5PM" }] * }); * // Returns: * // { * // user_id: 1, * // user_info: { first_name: "John", last_name: "Doe" }, * // user_activities: [{ login_time: "10AM", logout_time: "5PM" }] * // } */ export function keysToSnakeCase( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. obj: Record<string, any> // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. ): Record<string, any> { return Object.keys(obj).reduce( (acc, key) => { const newKey = decamelize(key, { preserveConsecutiveUppercase: true, }).replace(".", "_") let value = obj[key] if (value && typeof value === "object" && !Array.isArray(value)) { value = keysToSnakeCase(value) } if (Array.isArray(value)) { value = value.map(item => item !== null && typeof item === "object" ? keysToSnakeCase(item) : item ) } acc[newKey] = value return acc }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. {} as Record<string, any> ) } // TODO: Update all imports to use @streamlit/utils and remove this line. export { isNullOrUndefined, notNullOrUndefined } from "@streamlit/utils"