diff --git a/.circleci/config.yml b/.circleci/config.yml index 09a07c1c75..c8b784ca46 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2.1 orbs: win: circleci/windows@2.4.0 percy: percy/agent@0.1.3 - browser-tools: circleci/browser-tools@1.4.3 + browser-tools: circleci/browser-tools@1.4.6 jobs: artifacts: @@ -102,11 +102,13 @@ jobs: steps: - checkout + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: key: dep-{{ checksum ".circleci/config.yml" }}-{{ checksum "ver.txt" }}-{{ checksum "requires-all.txt" }} - - browser-tools/install-browser-tools + - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 - run: name: ️️🏗️ pip dev requirements command: | @@ -176,11 +178,13 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: key: dep-{{ checksum ".circleci/config.yml" }}-{{ checksum "ver.txt" }}-{{ checksum "requires-all.txt" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - attach_workspace: @@ -291,11 +295,13 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: key: dep-{{ checksum ".circleci/config.yml" }}-{{ checksum "ver.txt" }}-{{ checksum "requires-all.txt" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - attach_workspace: @@ -358,6 +364,7 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: @@ -365,6 +372,7 @@ jobs: - restore_cache: key: html-{{ checksum "components/dash-html-components/package.json" }}-{{ checksum "components/dash-html-components/package-lock.json" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - attach_workspace: @@ -432,11 +440,13 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: key: dep-{{ checksum ".circleci/config.yml" }}-{{ checksum "ver.txt" }}-{{ checksum "requires-all.txt" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - attach_workspace: @@ -484,6 +494,7 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - run: echo $PYVERSION > ver.txt - run: cat requires-*.txt > requires-all.txt - restore_cache: @@ -491,6 +502,7 @@ jobs: - restore_cache: key: table-{{ checksum "components/dash-table/package.json" }}-{{ checksum "components/dash-table/package-lock.json" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - attach_workspace: @@ -524,9 +536,11 @@ jobs: steps: - checkout: path: ~/dash + - run: sudo apt-get update - restore_cache: key: dep-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }} - browser-tools/install-browser-tools: + chrome-version: 116.0.5845.110 install-firefox: false install-geckodriver: false - run: diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bc58eed4..9c3bb0e7d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2634](https://github.com/plotly/dash/pull/2634) Fix deprecation warning on pkg_resources, fix [#2631](https://github.com/plotly/dash/issues/2631) +## Changed + +- [#2635](https://github.com/plotly/dash/pull/2635) Get proper app module name, remove need to give `__name__` to Dash constructor. + ## [2.13.0] 2023-08-28 ## Changed diff --git a/components/dash-core-components/src/components/Input.react.js b/components/dash-core-components/src/components/Input.react.js index 62d3834b79..d4656fddae 100644 --- a/components/dash-core-components/src/components/Input.react.js +++ b/components/dash-core-components/src/components/Input.react.js @@ -1,4 +1,4 @@ -import {isNil, omit} from 'ramda'; +import {isNil, pick} from 'ramda'; import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import isNumeric from 'fast-isnumeric'; @@ -9,6 +9,30 @@ const convert = val => (isNumeric(val) ? +val : NaN); const isEquivalent = (v1, v2) => v1 === v2 || (isNaN(v1) && isNaN(v2)); +const inputProps = [ + 'type', + 'placeholder', + 'inputMode', + 'autoComplete', + 'readOnly', + 'required', + 'autoFocus', + 'disabled', + 'list', + 'multiple', + 'spellCheck', + 'name', + 'min', + 'max', + 'step', + 'minLength', + 'maxLength', + 'pattern', + 'size', + 'style', + 'id', +]; + /** * A basic HTML input control for entering text, numbers, or passwords. * @@ -84,23 +108,7 @@ export default class Input extends PureComponent { onChange={this.onChange} onKeyPress={this.onKeyPress} {...valprops} - {...omit( - [ - 'className', - 'debounce', - 'value', - 'n_blur', - 'n_blur_timestamp', - 'n_submit', - 'n_submit_timestamp', - 'selectionDirection', - 'selectionEnd', - 'selectionStart', - 'setProps', - 'loading_state', - ], - this.props - )} + {...pick(inputProps, this.props)} /> ); } diff --git a/components/dash-core-components/src/components/Textarea.react.js b/components/dash-core-components/src/components/Textarea.react.js index cc8a8f60a4..8f155c05f2 100644 --- a/components/dash-core-components/src/components/Textarea.react.js +++ b/components/dash-core-components/src/components/Textarea.react.js @@ -1,6 +1,34 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {omit} from 'ramda'; +import {pick} from 'ramda'; + +const textAreaProps = [ + 'id', + 'autoFocus', + 'cols', + 'disabled', + 'form', + 'maxLength', + 'minLength', + 'name', + 'placeholder', + 'readOnly', + 'required', + 'rows', + 'wrap', + 'accessKey', + 'className', + 'contentEditable', + 'contextMenu', + 'dir', + 'draggable', + 'hidden', + 'lang', + 'spellCheck', + 'style', + 'tabIndex', + 'title', +]; /** * A basic HTML textarea for entering multiline text. @@ -31,7 +59,7 @@ export default class Textarea extends Component { n_clicks_timestamp: Date.now(), }); }} - {...omit(['setProps', 'value'], this.props)} + {...pick(textAreaProps, this.props)} /> ); } diff --git a/components/dash-core-components/src/fragments/RangeSlider.react.js b/components/dash-core-components/src/fragments/RangeSlider.react.js index f2e87d1939..5e93772a0b 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.react.js +++ b/components/dash-core-components/src/fragments/RangeSlider.react.js @@ -1,5 +1,5 @@ import React, {Component} from 'react'; -import {assoc, omit, isNil} from 'ramda'; +import {assoc, pick, isNil} from 'ramda'; import {Range, createSliderWithTooltip} from 'rc-slider'; import computeSliderStyle from '../utils/computeSliderStyle'; @@ -12,6 +12,20 @@ import { } from '../utils/computeSliderMarkers'; import {propTypes, defaultProps} from '../components/RangeSlider.react'; +const sliderProps = [ + 'min', + 'max', + 'allowCross', + 'pushable', + 'disabled', + 'count', + 'dots', + 'included', + 'tooltip', + 'vertical', + 'id', +]; + export default class RangeSlider extends Component { constructor(props) { super(props); @@ -112,19 +126,7 @@ export default class RangeSlider extends Component { ? null : calcStep(min, max, step) } - {...omit( - [ - 'className', - 'value', - 'drag_value', - 'setProps', - 'marks', - 'updatemode', - 'verticalHeight', - 'step', - ], - this.props - )} + {...pick(sliderProps, this.props)} /> ); diff --git a/components/dash-core-components/src/fragments/Slider.react.js b/components/dash-core-components/src/fragments/Slider.react.js index bf84971c63..2878658b0d 100644 --- a/components/dash-core-components/src/fragments/Slider.react.js +++ b/components/dash-core-components/src/fragments/Slider.react.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; import ReactSlider, {createSliderWithTooltip} from 'rc-slider'; -import {assoc, isNil, omit} from 'ramda'; +import {assoc, isNil, pick} from 'ramda'; import computeSliderStyle from '../utils/computeSliderStyle'; import 'rc-slider/assets/index.css'; @@ -12,6 +12,17 @@ import { } from '../utils/computeSliderMarkers'; import {propTypes, defaultProps} from '../components/Slider.react'; +const sliderProps = [ + 'min', + 'max', + 'disabled', + 'dots', + 'included', + 'tooltip', + 'vertical', + 'id', +]; + /** * A slider component with a single handle. */ @@ -115,19 +126,7 @@ export default class Slider extends Component { ? null : calcStep(min, max, step) } - {...omit( - [ - 'className', - 'setProps', - 'updatemode', - 'value', - 'drag_value', - 'marks', - 'verticalHeight', - 'step', - ], - this.props - )} + {...pick(sliderProps, this.props)} /> ); diff --git a/dash/_utils.py b/dash/_utils.py index 2e9bc33a2d..cd4638e0a9 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -10,6 +10,7 @@ import json import secrets import string +import inspect from html import escape from functools import wraps from typing import Union @@ -281,3 +282,12 @@ def hooks_to_js_object(hooks: Union[RendererHooks, None]) -> str: def parse_version(version): return tuple(int(s) for s in version.split(".")) + + +def get_caller_name(name: str): + stack = inspect.stack() + for s in stack: + for code in s.code_context: + if f"{name}(" in code: + return s.frame.f_locals.get("__name__", "__main__") + return "__main__" diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index 589821c80b..283d040c6b 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -1,4 +1,4 @@ -import {connect} from 'react-redux'; +import {batch, connect} from 'react-redux'; import {includes, isEmpty} from 'ramda'; import React, {useEffect, useRef, useState, createContext} from 'react'; import PropTypes from 'prop-types'; @@ -136,64 +136,73 @@ function storeEffect(props, events, setErrorLoading) { layoutRequest } = props; - if (isEmpty(layoutRequest)) { - if (typeof hooks.layout_pre === 'function') { - hooks.layout_pre(); - } - dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); - } else if (layoutRequest.status === STATUS.OK) { - if (isEmpty(layout)) { - if (typeof hooks.layout_post === 'function') { - hooks.layout_post(layoutRequest.content); + batch(() => { + if (isEmpty(layoutRequest)) { + if (typeof hooks.layout_pre === 'function') { + hooks.layout_pre(); + } + dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); + } else if (layoutRequest.status === STATUS.OK) { + if (isEmpty(layout)) { + if (typeof hooks.layout_post === 'function') { + hooks.layout_post(layoutRequest.content); + } + const finalLayout = applyPersistence( + layoutRequest.content, + dispatch + ); + dispatch( + setPaths( + computePaths(finalLayout, [], null, events.current) + ) + ); + dispatch(setLayout(finalLayout)); } - const finalLayout = applyPersistence( - layoutRequest.content, - dispatch + } + + if (isEmpty(dependenciesRequest)) { + dispatch( + apiThunk('_dash-dependencies', 'GET', 'dependenciesRequest') ); + } else if ( + dependenciesRequest.status === STATUS.OK && + isEmpty(graphs) + ) { dispatch( - setPaths(computePaths(finalLayout, [], null, events.current)) + setGraphs( + computeGraphs( + dependenciesRequest.content, + dispatchError(dispatch) + ) + ) ); - dispatch(setLayout(finalLayout)); } - } - if (isEmpty(dependenciesRequest)) { - dispatch(apiThunk('_dash-dependencies', 'GET', 'dependenciesRequest')); - } else if (dependenciesRequest.status === STATUS.OK && isEmpty(graphs)) { - dispatch( - setGraphs( - computeGraphs( - dependenciesRequest.content, - dispatchError(dispatch) - ) - ) - ); - } - - if ( - // dependenciesRequest and its computed stores - dependenciesRequest.status === STATUS.OK && - !isEmpty(graphs) && - // LayoutRequest and its computed stores - layoutRequest.status === STATUS.OK && - !isEmpty(layout) && - // Hasn't already hydrated - appLifecycle === getAppState('STARTED') - ) { - let hasError = false; - try { - dispatch(hydrateInitialOutputs(dispatchError(dispatch))); - } catch (err) { - // Display this error in devtools, unless we have errors - // already, in which case we assume this new one is moot - if (!error.frontEnd.length && !error.backEnd.length) { - dispatch(onError({type: 'backEnd', error: err})); + if ( + // dependenciesRequest and its computed stores + dependenciesRequest.status === STATUS.OK && + !isEmpty(graphs) && + // LayoutRequest and its computed stores + layoutRequest.status === STATUS.OK && + !isEmpty(layout) && + // Hasn't already hydrated + appLifecycle === getAppState('STARTED') + ) { + let hasError = false; + try { + dispatch(hydrateInitialOutputs(dispatchError(dispatch))); + } catch (err) { + // Display this error in devtools, unless we have errors + // already, in which case we assume this new one is moot + if (!error.frontEnd.length && !error.backEnd.length) { + dispatch(onError({type: 'backEnd', error: err})); + } + hasError = true; + } finally { + setErrorLoading(hasError); } - hasError = true; - } finally { - setErrorLoading(hasError); } - } + }); } UnconnectedContainer.propTypes = { diff --git a/dash/dash.py b/dash/dash.py index 608dac7511..374689f781 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -55,6 +55,7 @@ gen_salt, hooks_to_js_object, parse_version, + get_caller_name, ) from . import _callback from . import _get_paths @@ -387,14 +388,16 @@ def __init__( # pylint: disable=too-many-statements ): _validate.check_obsolete(obsolete) + caller_name = get_caller_name(self.__class__.__name__) + # We have 3 cases: server is either True (we create the server), False # (defer server creation) or a Flask app instance (we use their server) if isinstance(server, flask.Flask): self.server = server if name is None: - name = getattr(server, "name", "__main__") + name = getattr(server, "name", caller_name) elif isinstance(server, bool): - name = name if name else "__main__" + name = name if name else caller_name self.server = flask.Flask(name) if server else None else: raise ValueError("server must be a Flask app or a boolean") diff --git a/tests/integration/long_callback/conftest.py b/tests/integration/long_callback/conftest.py new file mode 100644 index 0000000000..b701eea91a --- /dev/null +++ b/tests/integration/long_callback/conftest.py @@ -0,0 +1,15 @@ +import os + +import pytest + + +if "REDIS_URL" in os.environ: + managers = ["celery", "diskcache"] +else: + print("Skipping celery tests because REDIS_URL is not defined") + managers = ["diskcache"] + + +@pytest.fixture(params=managers) +def manager(request): + return request.param diff --git a/tests/integration/long_callback/test_basic_long_callback.py b/tests/integration/long_callback/test_basic_long_callback.py deleted file mode 100644 index fc19d3c0af..0000000000 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ /dev/null @@ -1,599 +0,0 @@ -import json -from multiprocessing import Lock -import os -from contextlib import contextmanager -import subprocess -import tempfile -import pytest -import shutil -import time -from flaky import flaky - -from dash.testing.application_runners import import_app -import psutil -import redis - -from . import utils - - -def kill(proc_pid): - process = psutil.Process(proc_pid) - for proc in process.children(recursive=True): - proc.kill() - process.kill() - - -if "REDIS_URL" in os.environ: - managers = ["celery", "diskcache"] -else: - print("Skipping celery tests because REDIS_URL is not defined") - managers = ["diskcache"] - - -@pytest.fixture(params=managers) -def manager(request): - return request.param - - -@contextmanager -def setup_long_callback_app(manager_name, app_name): - if manager_name == "celery": - os.environ["LONG_CALLBACK_MANAGER"] = "celery" - redis_url = os.environ["REDIS_URL"].rstrip("/") - os.environ["CELERY_BROKER"] = f"{redis_url}/0" - os.environ["CELERY_BACKEND"] = f"{redis_url}/1" - - # Clear redis of cached values - redis_conn = redis.Redis(host="localhost", port=6379, db=1) - cache_keys = redis_conn.keys() - if cache_keys: - redis_conn.delete(*cache_keys) - - worker = subprocess.Popen( - [ - "celery", - "-A", - f"tests.integration.long_callback.{app_name}:handle", - "worker", - "-P", - "prefork", - "--concurrency", - "2", - "--loglevel=info", - ], - preexec_fn=os.setpgrp, - stderr=subprocess.PIPE, - ) - # Wait for the worker to be ready, if you cancel before it is ready, the job - # will still be queued. - for line in iter(worker.stderr.readline, ""): - if "ready" in line.decode(): - break - - try: - yield import_app(f"tests.integration.long_callback.{app_name}") - finally: - # Interval may run one more time after settling on final app state - # Sleep for 1 interval of time - time.sleep(0.5) - os.environ.pop("LONG_CALLBACK_MANAGER") - os.environ.pop("CELERY_BROKER") - os.environ.pop("CELERY_BACKEND") - kill(worker.pid) - - elif manager_name == "diskcache": - os.environ["LONG_CALLBACK_MANAGER"] = "diskcache" - cache_directory = tempfile.mkdtemp(prefix="lc-diskcache-") - print(cache_directory) - os.environ["DISKCACHE_DIR"] = cache_directory - try: - app = import_app(f"tests.integration.long_callback.{app_name}") - yield app - finally: - # Interval may run one more time after settling on final app state - # Sleep for a couple of intervals - time.sleep(2.0) - - for job in utils.manager.running_jobs: - utils.manager.terminate_job(job) - - shutil.rmtree(cache_directory, ignore_errors=True) - os.environ.pop("LONG_CALLBACK_MANAGER") - os.environ.pop("DISKCACHE_DIR") - - -@flaky(max_runs=3) -def test_lcbc001_fast_input(dash_duo, manager): - """ - Make sure that we settle to the correct final value when handling rapid inputs - """ - lock = Lock() - with setup_long_callback_app(manager, "app1") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#output-1", "initial value", 15) - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - - for key in "hello world": - with lock: - input_.send_keys(key) - - dash_duo.wait_for_text_to_equal("#output-1", "hello world", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@flaky(max_runs=3) -def test_lcbc002_long_callback_running(dash_duo, manager): - with setup_long_callback_app(manager, "app2") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#result", "Not clicked", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - - # Click button and check that status has changed to "Running" - dash_duo.find_element("#button-1").click() - dash_duo.wait_for_text_to_equal("#status", "Running", 8) - - # Wait for calculation to finish, then check that status is "Finished" - dash_duo.wait_for_text_to_equal("#result", "Clicked 1 time(s)", 12) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - - # Click button twice and check that status has changed to "Running" - dash_duo.find_element("#button-1").click() - dash_duo.find_element("#button-1").click() - dash_duo.wait_for_text_to_equal("#status", "Running", 8) - - # Wait for calculation to finish, then check that status is "Finished" - dash_duo.wait_for_text_to_equal("#result", "Clicked 3 time(s)", 12) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@flaky(max_runs=3) -def test_lcbc003_long_callback_running_cancel(dash_duo, manager): - lock = Lock() - - with setup_long_callback_app(manager, "app3") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#result", "No results", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 6) - - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#result", "Processed 'initial value'", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 6) - - # Update input text box - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - - for key in "hello world": - with lock: - input_.send_keys(key) - - # Click run button and check that status has changed to "Running" - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Running", 8) - - # Then click Cancel button and make sure that the status changes to finish - # without update result - dash_duo.find_element("#cancel-button").click() - dash_duo.wait_for_text_to_equal("#result", "Processed 'initial value'", 12) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - - # Click run button again, and let it finish - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Running", 8) - dash_duo.wait_for_text_to_equal("#result", "Processed 'hello world'", 8) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@flaky(max_runs=3) -def test_lcbc004_long_callback_progress(dash_duo, manager): - with setup_long_callback_app(manager, "app4") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "No results", 8) - - # click run and check that status eventually cycles to 2/4 - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - - # Then click Cancel button and make sure that the status changes to finish - # without updating result - dash_duo.find_element("#cancel-button").click() - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "No results", 8) - - # Click run button and allow callback to finish - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) - - # Click run button again with same input. - # without caching, this should rerun callback and display progress - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@pytest.mark.skip(reason="Timeout often") -def test_lcbc005_long_callback_caching(dash_duo, manager): - lock = Lock() - - with setup_long_callback_app(manager, "app5") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) - - # Update input text box to BBB - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - for key in "BBB": - with lock: - input_.send_keys(key) - - # Click run button and check that status eventually cycles to 2/4 - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result", "Result for 'BBB'", 8) - - # Update input text box back to AAA - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - for key in "AAA": - with lock: - input_.send_keys(key) - - # Click run button and this time the cached result is used, - # So we can get the result right away - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) - - # Update input text box back to BBB - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - for key in "BBB": - with lock: - input_.send_keys(key) - - # Click run button and this time the cached result is used, - # So we can get the result right away - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "Result for 'BBB'", 8) - - # Update input text box back to AAA - input_ = dash_duo.find_element("#input") - dash_duo.clear_input(input_) - for key in "AAA": - with lock: - input_.send_keys(key) - - # Change cache key - app._cache_key.value = 1 - - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@flaky(max_runs=3) -def test_lcbc006_long_callback_caching_multi(dash_duo, manager): - lock = Lock() - - with setup_long_callback_app(manager, "app6") as app: - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status1", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) - - # Check initial status/output of second long_callback - # prevent_initial_callback=True means no calculation should have run yet - dash_duo.wait_for_text_to_equal("#status2", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result2", "No results", 8) - - # Click second run button - dash_duo.find_element("#run-button2").click() - dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) - - # Update input text box to BBB - input_ = dash_duo.find_element("#input1") - dash_duo.clear_input(input_) - for key in "BBB": - with lock: - input_.send_keys(key) - - # Click run button and check that status eventually cycles to 2/4 - dash_duo.find_element("#run-button1").click() - dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status1", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result1", "Result for 'BBB'", 8) - - # Check there were no changes in second long_callback output - dash_duo.wait_for_text_to_equal("#status2", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) - - # Update input text box back to AAA - input_ = dash_duo.find_element("#input1") - dash_duo.clear_input(input_) - for key in "AAA": - with lock: - input_.send_keys(key) - - # Click run button and this time the cached result is used, - # So we can get the result right away - dash_duo.find_element("#run-button1").click() - dash_duo.wait_for_text_to_equal("#status1", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) - - # Update input text box back to BBB - input_ = dash_duo.find_element("#input1") - dash_duo.clear_input(input_) - for key in "BBB": - with lock: - input_.send_keys(key) - - # Click run button and this time the cached result is used, - # So we can get the result right away - dash_duo.find_element("#run-button1").click() - dash_duo.wait_for_text_to_equal("#status1", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result1", "Result for 'BBB'", 8) - - # Update second input text box to BBB, make sure there is not a cache hit - input_ = dash_duo.find_element("#input2") - dash_duo.clear_input(input_) - for key in "BBB": - with lock: - input_.send_keys(key) - dash_duo.find_element("#run-button2").click() - dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result2", "Result for 'BBB'", 8) - - # Update second input text box back to aaa, check for cache hit - input_ = dash_duo.find_element("#input2") - dash_duo.clear_input(input_) - for key in "aaa": - with lock: - input_.send_keys(key) - dash_duo.find_element("#run-button2").click() - dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) - - # Update input text box back to AAA - input_ = dash_duo.find_element("#input1") - dash_duo.clear_input(input_) - for key in "AAA": - with lock: - input_.send_keys(key) - - # Change cache key to cause cache miss - app._cache_key.value = 1 - - # Check for cache miss for first long_callback - dash_duo.find_element("#run-button1").click() - dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status1", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) - - # Check for cache miss for second long_callback - dash_duo.find_element("#run-button2").click() - dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -@flaky(max_runs=3) -def test_lcbc007_validation_layout(dash_duo, manager): - with setup_long_callback_app(manager, "app7") as app: - dash_duo.start_server(app) - - # Show layout - dash_duo.find_element("#show-layout-button").click() - - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "No results", 8) - - # click run and check that status eventually cycles to 2/4 - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - - # Then click Cancel button and make sure that the status changes to finish - # without updating result - dash_duo.find_element("#cancel-button").click() - dash_duo.wait_for_text_to_equal("#status", "Finished", 8) - dash_duo.wait_for_text_to_equal("#result", "No results", 8) - - # Click run button and allow callback to finish - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) - - # Click run button again with same input. - # without caching, this should rerun callback and display progress - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) - dash_duo.wait_for_text_to_equal("#status", "Finished", 15) - dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) - - assert not dash_duo.redux_state_is_loading - assert dash_duo.get_logs() == [] - - -def test_lcbc008_long_callbacks_error(dash_duo, manager): - with setup_long_callback_app(manager, "app_error") as app: - dash_duo.start_server( - app, - debug=True, - use_reloader=False, - use_debugger=True, - dev_tools_hot_reload=False, - dev_tools_ui=True, - ) - - clicker = dash_duo.wait_for_element("#button") - - def click_n_wait(): - clicker.click() - dash_duo.wait_for_element("#button:disabled") - dash_duo.wait_for_element("#button:not([disabled])") - - clicker.click() - dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times") - - click_n_wait() - dash_duo.wait_for_element(".dash-fe-error__title").click() - - dash_duo.driver.switch_to.frame(dash_duo.find_element("iframe")) - assert ( - "dash.exceptions.LongCallbackError: An error occurred inside a long callback:" - in dash_duo.wait_for_element(".errormsg").text - ) - dash_duo.driver.switch_to.default_content() - - click_n_wait() - dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times") - - click_n_wait() - dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times") - click_n_wait() - dash_duo.wait_for_text_to_equal("#output", "Clicked 5 times") - - def make_expect(n): - return [str(x) for x in range(1, n + 1)] + ["" for _ in range(n + 1, 4)] - - multi = dash_duo.wait_for_element("#multi-output") - - for i in range(1, 4): - with app.test_lock: - multi.click() - dash_duo.wait_for_element("#multi-output:disabled") - expect = make_expect(i) - dash_duo.wait_for_text_to_equal("#output-status", f"Updated: {i}") - for j, e in enumerate(expect): - assert dash_duo.find_element(f"#output{j + 1}").text == e - - -def test_lcbc009_short_interval(dash_duo, manager): - with setup_long_callback_app(manager, "app_short_interval") as app: - dash_duo.start_server(app) - dash_duo.find_element("#run-button").click() - dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) - dash_duo.wait_for_text_to_equal("#status", "Finished", 12) - dash_duo.wait_for_text_to_equal("#result", "Clicked '1'") - - time.sleep(2) - # Ensure the progress is still not running - assert dash_duo.find_element("#status").text == "Finished" - - -def test_lcbc010_side_updates(dash_duo, manager): - with setup_long_callback_app(manager, "app_side_update") as app: - dash_duo.start_server(app) - dash_duo.find_element("#run-button").click() - for i in range(1, 4): - dash_duo.wait_for_text_to_equal("#side-status", f"Side Progress {i}/4") - - -def test_lcbc011_long_pattern_matching(dash_duo, manager): - with setup_long_callback_app(manager, "app_pattern_matching") as app: - dash_duo.start_server(app) - for i in range(1, 4): - for _ in range(i): - dash_duo.find_element(f"button:nth-child({i})").click() - - dash_duo.wait_for_text_to_equal("#result", f"Clicked '{i}'") - - -def test_lcbc012_long_callback_ctx(dash_duo, manager): - with setup_long_callback_app(manager, "app_callback_ctx") as app: - dash_duo.start_server(app) - dash_duo.find_element("button:nth-child(1)").click() - dash_duo.wait_for_text_to_equal("#running", "off") - - output = json.loads(dash_duo.find_element("#result").text) - - assert output["triggered"]["index"] == 0 - - -def test_lcbc013_unordered_state_input(dash_duo, manager): - with setup_long_callback_app(manager, "app_unordered") as app: - dash_duo.start_server(app) - dash_duo.find_element("#click").click() - - dash_duo.wait_for_text_to_equal("#output", "stored") - - -def test_lcbc014_progress_delete(dash_duo, manager): - with setup_long_callback_app(manager, "app_progress_delete") as app: - dash_duo.start_server(app) - dash_duo.find_element("#start").click() - dash_duo.wait_for_text_to_equal("#output", "done") - - assert dash_duo.find_element("#progress-counter").text == "2" - - -def test_lcbc015_diff_outputs_same_func(dash_duo, manager): - with setup_long_callback_app(manager, "app_diff_outputs") as app: - dash_duo.start_server(app) - - for i in range(1, 3): - dash_duo.find_element(f"#button-{i}").click() - dash_duo.wait_for_text_to_equal(f"#output-{i}", f"Clicked on {i}") - - -def test_lcbc016_multi_page_cancel(dash_duo, manager): - with setup_long_callback_app(manager, "app_page_cancel") as app: - dash_duo.start_server(app) - dash_duo.find_element("#start1").click() - dash_duo.wait_for_text_to_equal("#progress1", "running") - dash_duo.find_element("#shared_cancel").click() - dash_duo.wait_for_text_to_equal("#progress1", "idle") - time.sleep(2.1) - dash_duo.wait_for_text_to_equal("#output1", "initial") - - dash_duo.find_element("#start1").click() - dash_duo.wait_for_text_to_equal("#progress1", "running") - dash_duo.find_element("#cancel1").click() - dash_duo.wait_for_text_to_equal("#progress1", "idle") - time.sleep(2.1) - dash_duo.wait_for_text_to_equal("#output1", "initial") - - dash_duo.server_url = dash_duo.server_url + "/2" - - dash_duo.find_element("#start2").click() - dash_duo.wait_for_text_to_equal("#progress2", "running") - dash_duo.find_element("#shared_cancel").click() - dash_duo.wait_for_text_to_equal("#progress2", "idle") - time.sleep(2.1) - dash_duo.wait_for_text_to_equal("#output2", "initial") - - dash_duo.find_element("#start2").click() - dash_duo.wait_for_text_to_equal("#progress2", "running") - dash_duo.find_element("#cancel2").click() - dash_duo.wait_for_text_to_equal("#progress2", "idle") - time.sleep(2.1) - dash_duo.wait_for_text_to_equal("#output2", "initial") diff --git a/tests/integration/long_callback/test_basic_long_callback001.py b/tests/integration/long_callback/test_basic_long_callback001.py new file mode 100644 index 0000000000..e774443956 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback001.py @@ -0,0 +1,32 @@ +import sys +from multiprocessing import Lock + +import pytest +from flaky import flaky + +from .utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc001_fast_input(dash_duo, manager): + """ + Make sure that we settle to the correct final value when handling rapid inputs + """ + lock = Lock() + with setup_long_callback_app(manager, "app1") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-1", "initial value", 15) + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + + for key in "hello world": + with lock: + input_.send_keys(key) + + dash_duo.wait_for_text_to_equal("#output-1", "hello world", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback002.py b/tests/integration/long_callback/test_basic_long_callback002.py new file mode 100644 index 0000000000..9d1de381f2 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback002.py @@ -0,0 +1,37 @@ +import sys + +import pytest +from flaky import flaky + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc002_long_callback_running(dash_duo, manager): + with setup_long_callback_app(manager, "app2") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#result", "Not clicked", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + + # Click button and check that status has changed to "Running" + dash_duo.find_element("#button-1").click() + dash_duo.wait_for_text_to_equal("#status", "Running", 8) + + # Wait for calculation to finish, then check that status is "Finished" + dash_duo.wait_for_text_to_equal("#result", "Clicked 1 time(s)", 12) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + + # Click button twice and check that status has changed to "Running" + dash_duo.find_element("#button-1").click() + dash_duo.find_element("#button-1").click() + dash_duo.wait_for_text_to_equal("#status", "Running", 8) + + # Wait for calculation to finish, then check that status is "Finished" + dash_duo.wait_for_text_to_equal("#result", "Clicked 3 time(s)", 12) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback003.py b/tests/integration/long_callback/test_basic_long_callback003.py new file mode 100644 index 0000000000..c8825a6e0f --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback003.py @@ -0,0 +1,51 @@ +import sys +from multiprocessing import Lock + +import pytest +from flaky import flaky + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc003_long_callback_running_cancel(dash_duo, manager): + lock = Lock() + + with setup_long_callback_app(manager, "app3") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#result", "No results", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 6) + + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#result", "Processed 'initial value'", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 6) + + # Update input text box + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + + for key in "hello world": + with lock: + input_.send_keys(key) + + # Click run button and check that status has changed to "Running" + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Running", 8) + + # Then click Cancel button and make sure that the status changes to finish + # without update result + dash_duo.find_element("#cancel-button").click() + dash_duo.wait_for_text_to_equal("#result", "Processed 'initial value'", 12) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + + # Click run button again, and let it finish + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Running", 8) + dash_duo.wait_for_text_to_equal("#result", "Processed 'hello world'", 8) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback004.py b/tests/integration/long_callback/test_basic_long_callback004.py new file mode 100644 index 0000000000..06080ada7e --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback004.py @@ -0,0 +1,43 @@ +import sys + +import pytest +from flaky import flaky + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc004_long_callback_progress(dash_duo, manager): + with setup_long_callback_app(manager, "app4") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "No results", 8) + + # click run and check that status eventually cycles to 2/4 + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + + # Then click Cancel button and make sure that the status changes to finish + # without updating result + dash_duo.find_element("#cancel-button").click() + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "No results", 8) + + # Click run button and allow callback to finish + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) + + # Click run button again with same input. + # without caching, this should rerun callback and display progress + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback005.py b/tests/integration/long_callback/test_basic_long_callback005.py new file mode 100644 index 0000000000..c92c6ae00b --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback005.py @@ -0,0 +1,77 @@ +import sys +from multiprocessing import Lock + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@pytest.mark.skip(reason="Timeout often") +def test_lcbc005_long_callback_caching(dash_duo, manager): + lock = Lock() + + with setup_long_callback_app(manager, "app5") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) + + # Update input text box to BBB + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + for key in "BBB": + with lock: + input_.send_keys(key) + + # Click run button and check that status eventually cycles to 2/4 + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result", "Result for 'BBB'", 8) + + # Update input text box back to AAA + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + for key in "AAA": + with lock: + input_.send_keys(key) + + # Click run button and this time the cached result is used, + # So we can get the result right away + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) + + # Update input text box back to BBB + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + for key in "BBB": + with lock: + input_.send_keys(key) + + # Click run button and this time the cached result is used, + # So we can get the result right away + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "Result for 'BBB'", 8) + + # Update input text box back to AAA + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + for key in "AAA": + with lock: + input_.send_keys(key) + + # Change cache key + app._cache_key.value = 1 + + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result", "Result for 'AAA'", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback006.py b/tests/integration/long_callback/test_basic_long_callback006.py new file mode 100644 index 0000000000..3ae58ce769 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback006.py @@ -0,0 +1,120 @@ +import sys +from multiprocessing import Lock + +import pytest +from flaky import flaky + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc006_long_callback_caching_multi(dash_duo, manager): + lock = Lock() + + with setup_long_callback_app(manager, "app6") as app: + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status1", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) + + # Check initial status/output of second long_callback + # prevent_initial_callback=True means no calculation should have run yet + dash_duo.wait_for_text_to_equal("#status2", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result2", "No results", 8) + + # Click second run button + dash_duo.find_element("#run-button2").click() + dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) + + # Update input text box to BBB + input_ = dash_duo.find_element("#input1") + dash_duo.clear_input(input_) + for key in "BBB": + with lock: + input_.send_keys(key) + + # Click run button and check that status eventually cycles to 2/4 + dash_duo.find_element("#run-button1").click() + dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status1", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result1", "Result for 'BBB'", 8) + + # Check there were no changes in second long_callback output + dash_duo.wait_for_text_to_equal("#status2", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) + + # Update input text box back to AAA + input_ = dash_duo.find_element("#input1") + dash_duo.clear_input(input_) + for key in "AAA": + with lock: + input_.send_keys(key) + + # Click run button and this time the cached result is used, + # So we can get the result right away + dash_duo.find_element("#run-button1").click() + dash_duo.wait_for_text_to_equal("#status1", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) + + # Update input text box back to BBB + input_ = dash_duo.find_element("#input1") + dash_duo.clear_input(input_) + for key in "BBB": + with lock: + input_.send_keys(key) + + # Click run button and this time the cached result is used, + # So we can get the result right away + dash_duo.find_element("#run-button1").click() + dash_duo.wait_for_text_to_equal("#status1", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result1", "Result for 'BBB'", 8) + + # Update second input text box to BBB, make sure there is not a cache hit + input_ = dash_duo.find_element("#input2") + dash_duo.clear_input(input_) + for key in "BBB": + with lock: + input_.send_keys(key) + dash_duo.find_element("#run-button2").click() + dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result2", "Result for 'BBB'", 8) + + # Update second input text box back to aaa, check for cache hit + input_ = dash_duo.find_element("#input2") + dash_duo.clear_input(input_) + for key in "aaa": + with lock: + input_.send_keys(key) + dash_duo.find_element("#run-button2").click() + dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) + + # Update input text box back to AAA + input_ = dash_duo.find_element("#input1") + dash_duo.clear_input(input_) + for key in "AAA": + with lock: + input_.send_keys(key) + + # Change cache key to cause cache miss + app._cache_key.value = 1 + + # Check for cache miss for first long_callback + dash_duo.find_element("#run-button1").click() + dash_duo.wait_for_text_to_equal("#status1", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status1", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result1", "Result for 'AAA'", 8) + + # Check for cache miss for second long_callback + dash_duo.find_element("#run-button2").click() + dash_duo.wait_for_text_to_equal("#status2", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status2", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result2", "Result for 'aaa'", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback007.py b/tests/integration/long_callback/test_basic_long_callback007.py new file mode 100644 index 0000000000..a8970b2ff2 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback007.py @@ -0,0 +1,47 @@ +import sys + +import pytest +from flaky import flaky + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +@flaky(max_runs=3) +def test_lcbc007_validation_layout(dash_duo, manager): + with setup_long_callback_app(manager, "app7") as app: + dash_duo.start_server(app) + + # Show layout + dash_duo.find_element("#show-layout-button").click() + + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "No results", 8) + + # click run and check that status eventually cycles to 2/4 + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + + # Then click Cancel button and make sure that the status changes to finish + # without updating result + dash_duo.find_element("#cancel-button").click() + dash_duo.wait_for_text_to_equal("#status", "Finished", 8) + dash_duo.wait_for_text_to_equal("#result", "No results", 8) + + # Click run button and allow callback to finish + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) + + # Click run button again with same input. + # without caching, this should rerun callback and display progress + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 15) + dash_duo.wait_for_text_to_equal("#status", "Finished", 15) + dash_duo.wait_for_text_to_equal("#result", "Processed 'hello, world'", 8) + + assert not dash_duo.redux_state_is_loading + assert dash_duo.get_logs() == [] diff --git a/tests/integration/long_callback/test_basic_long_callback008.py b/tests/integration/long_callback/test_basic_long_callback008.py new file mode 100644 index 0000000000..dad22394d4 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback008.py @@ -0,0 +1,62 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc008_long_callbacks_error(dash_duo, manager): + with setup_long_callback_app(manager, "app_error") as app: + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + dev_tools_ui=True, + ) + + clicker = dash_duo.wait_for_element("#button") + + def click_n_wait(): + clicker.click() + dash_duo.wait_for_element("#button:disabled") + dash_duo.wait_for_element("#button:not([disabled])") + + clicker.click() + dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times") + + click_n_wait() + dash_duo.wait_for_element(".dash-fe-error__title").click() + + dash_duo.driver.switch_to.frame(dash_duo.find_element("iframe")) + assert ( + "dash.exceptions.LongCallbackError: An error occurred inside a long callback:" + in dash_duo.wait_for_element(".errormsg").text + ) + dash_duo.driver.switch_to.default_content() + + click_n_wait() + dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times") + + click_n_wait() + dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times") + click_n_wait() + dash_duo.wait_for_text_to_equal("#output", "Clicked 5 times") + + def make_expect(n): + return [str(x) for x in range(1, n + 1)] + ["" for _ in range(n + 1, 4)] + + multi = dash_duo.wait_for_element("#multi-output") + + for i in range(1, 4): + with app.test_lock: + multi.click() + dash_duo.wait_for_element("#multi-output:disabled") + expect = make_expect(i) + dash_duo.wait_for_text_to_equal("#output-status", f"Updated: {i}") + for j, e in enumerate(expect): + assert dash_duo.find_element(f"#output{j + 1}").text == e diff --git a/tests/integration/long_callback/test_basic_long_callback009.py b/tests/integration/long_callback/test_basic_long_callback009.py new file mode 100644 index 0000000000..c70b9736ad --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback009.py @@ -0,0 +1,22 @@ +import sys +import time + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc009_short_interval(dash_duo, manager): + with setup_long_callback_app(manager, "app_short_interval") as app: + dash_duo.start_server(app) + dash_duo.find_element("#run-button").click() + dash_duo.wait_for_text_to_equal("#status", "Progress 2/4", 20) + dash_duo.wait_for_text_to_equal("#status", "Finished", 12) + dash_duo.wait_for_text_to_equal("#result", "Clicked '1'") + + time.sleep(2) + # Ensure the progress is still not running + assert dash_duo.find_element("#status").text == "Finished" diff --git a/tests/integration/long_callback/test_basic_long_callback010.py b/tests/integration/long_callback/test_basic_long_callback010.py new file mode 100644 index 0000000000..e6ba2fca44 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback010.py @@ -0,0 +1,16 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc010_side_updates(dash_duo, manager): + with setup_long_callback_app(manager, "app_side_update") as app: + dash_duo.start_server(app) + dash_duo.find_element("#run-button").click() + for i in range(1, 4): + dash_duo.wait_for_text_to_equal("#side-status", f"Side Progress {i}/4") diff --git a/tests/integration/long_callback/test_basic_long_callback011.py b/tests/integration/long_callback/test_basic_long_callback011.py new file mode 100644 index 0000000000..94736f4b67 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback011.py @@ -0,0 +1,18 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc011_long_pattern_matching(dash_duo, manager): + with setup_long_callback_app(manager, "app_pattern_matching") as app: + dash_duo.start_server(app) + for i in range(1, 4): + for _ in range(i): + dash_duo.find_element(f"button:nth-child({i})").click() + + dash_duo.wait_for_text_to_equal("#result", f"Clicked '{i}'") diff --git a/tests/integration/long_callback/test_basic_long_callback012.py b/tests/integration/long_callback/test_basic_long_callback012.py new file mode 100644 index 0000000000..e646686f38 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback012.py @@ -0,0 +1,20 @@ +import json +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc012_long_callback_ctx(dash_duo, manager): + with setup_long_callback_app(manager, "app_callback_ctx") as app: + dash_duo.start_server(app) + dash_duo.find_element("button:nth-child(1)").click() + dash_duo.wait_for_text_to_equal("#running", "off") + + output = json.loads(dash_duo.find_element("#result").text) + + assert output["triggered"]["index"] == 0 diff --git a/tests/integration/long_callback/test_basic_long_callback013.py b/tests/integration/long_callback/test_basic_long_callback013.py new file mode 100644 index 0000000000..451e03193e --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback013.py @@ -0,0 +1,16 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc013_unordered_state_input(dash_duo, manager): + with setup_long_callback_app(manager, "app_unordered") as app: + dash_duo.start_server(app) + dash_duo.find_element("#click").click() + + dash_duo.wait_for_text_to_equal("#output", "stored") diff --git a/tests/integration/long_callback/test_basic_long_callback014.py b/tests/integration/long_callback/test_basic_long_callback014.py new file mode 100644 index 0000000000..47ff2f1155 --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback014.py @@ -0,0 +1,17 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc014_progress_delete(dash_duo, manager): + with setup_long_callback_app(manager, "app_progress_delete") as app: + dash_duo.start_server(app) + dash_duo.find_element("#start").click() + dash_duo.wait_for_text_to_equal("#output", "done") + + assert dash_duo.find_element("#progress-counter").text == "2" diff --git a/tests/integration/long_callback/test_basic_long_callback015.py b/tests/integration/long_callback/test_basic_long_callback015.py new file mode 100644 index 0000000000..a35a8b19ee --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback015.py @@ -0,0 +1,17 @@ +import sys + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc015_diff_outputs_same_func(dash_duo, manager): + with setup_long_callback_app(manager, "app_diff_outputs") as app: + dash_duo.start_server(app) + + for i in range(1, 3): + dash_duo.find_element(f"#button-{i}").click() + dash_duo.wait_for_text_to_equal(f"#output-{i}", f"Clicked on {i}") diff --git a/tests/integration/long_callback/test_basic_long_callback016.py b/tests/integration/long_callback/test_basic_long_callback016.py new file mode 100644 index 0000000000..3d9e66a77e --- /dev/null +++ b/tests/integration/long_callback/test_basic_long_callback016.py @@ -0,0 +1,43 @@ +import sys +import time + +import pytest + +from tests.integration.long_callback.utils import setup_long_callback_app + + +@pytest.mark.skipif( + sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up" +) +def test_lcbc016_multi_page_cancel(dash_duo, manager): + with setup_long_callback_app(manager, "app_page_cancel") as app: + dash_duo.start_server(app) + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output1", "initial") + + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#cancel1").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output1", "initial") + + dash_duo.server_url = dash_duo.server_url + "/2" + + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output2", "initial") + + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#cancel2").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output2", "initial") diff --git a/tests/integration/long_callback/utils.py b/tests/integration/long_callback/utils.py index 3bd7e1310d..74eb5076b0 100644 --- a/tests/integration/long_callback/utils.py +++ b/tests/integration/long_callback/utils.py @@ -1,4 +1,12 @@ import os +import shutil +import subprocess +import tempfile +import time +from contextlib import contextmanager + +import psutil +import redis from dash.long_callback import DiskcacheManager @@ -49,3 +57,85 @@ def get_long_callback_manager(): manager = long_callback_manager return long_callback_manager + + +def kill(proc_pid): + process = psutil.Process(proc_pid) + for proc in process.children(recursive=True): + proc.kill() + process.kill() + + +@contextmanager +def setup_long_callback_app(manager_name, app_name): + from dash.testing.application_runners import import_app + + if manager_name == "celery": + os.environ["LONG_CALLBACK_MANAGER"] = "celery" + redis_url = os.environ["REDIS_URL"].rstrip("/") + os.environ["CELERY_BROKER"] = f"{redis_url}/0" + os.environ["CELERY_BACKEND"] = f"{redis_url}/1" + + # Clear redis of cached values + redis_conn = redis.Redis(host="localhost", port=6379, db=1) + cache_keys = redis_conn.keys() + if cache_keys: + redis_conn.delete(*cache_keys) + + worker = subprocess.Popen( + [ + "celery", + "-A", + f"tests.integration.long_callback.{app_name}:handle", + "worker", + "-P", + "prefork", + "--concurrency", + "2", + "--loglevel=info", + ], + preexec_fn=os.setpgrp, + stderr=subprocess.PIPE, + ) + # Wait for the worker to be ready, if you cancel before it is ready, the job + # will still be queued. + for line in iter(worker.stderr.readline, ""): + if "ready" in line.decode(): + break + + try: + yield import_app(f"tests.integration.long_callback.{app_name}") + finally: + # Interval may run one more time after settling on final app state + # Sleep for 1 interval of time + time.sleep(0.5) + os.environ.pop("LONG_CALLBACK_MANAGER") + os.environ.pop("CELERY_BROKER") + os.environ.pop("CELERY_BACKEND") + kill(worker.pid) + from dash import page_registry + + page_registry.clear() + + elif manager_name == "diskcache": + os.environ["LONG_CALLBACK_MANAGER"] = "diskcache" + cache_directory = tempfile.mkdtemp(prefix="lc-diskcache-") + print(cache_directory) + os.environ["DISKCACHE_DIR"] = cache_directory + try: + app = import_app(f"tests.integration.long_callback.{app_name}") + yield app + finally: + # Interval may run one more time after settling on final app state + # Sleep for a couple of intervals + time.sleep(2.0) + + for job in manager.running_jobs: + manager.terminate_job(job) + + shutil.rmtree(cache_directory, ignore_errors=True) + os.environ.pop("LONG_CALLBACK_MANAGER") + os.environ.pop("DISKCACHE_DIR") + from dash import page_registry + + page_registry.clear()