dispatch(redo())}
+ onClick={() => dispatch(redo)}
>
diff --git a/dash-renderer/src/components/error/GlobalErrorContainer.react.js b/dash-renderer/src/components/error/GlobalErrorContainer.react.js
index 28f344c5c0..6f61b07fcf 100644
--- a/dash-renderer/src/components/error/GlobalErrorContainer.react.js
+++ b/dash-renderer/src/components/error/GlobalErrorContainer.react.js
@@ -10,14 +10,11 @@ class UnconnectedGlobalErrorContainer extends Component {
}
render() {
- const {error, dependenciesRequest} = this.props;
+ const {error, graphs, children} = this.props;
return (
);
@@ -27,12 +24,12 @@ class UnconnectedGlobalErrorContainer extends Component {
UnconnectedGlobalErrorContainer.propTypes = {
children: PropTypes.object,
error: PropTypes.object,
- dependenciesRequest: PropTypes.object,
+ graphs: PropTypes.object,
};
const GlobalErrorContainer = connect(state => ({
error: state.error,
- dependenciesRequest: state.dependenciesRequest,
+ graphs: state.graphs,
}))(Radium(UnconnectedGlobalErrorContainer));
export default GlobalErrorContainer;
diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js
index cce9e9acff..e25c8cc2bc 100644
--- a/dash-renderer/src/components/error/menu/DebugMenu.react.js
+++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js
@@ -31,7 +31,7 @@ class DebugMenu extends Component {
toastsEnabled,
callbackGraphOpened,
} = this.state;
- const {error, dependenciesRequest} = this.props;
+ const {error, graphs} = this.props;
const menuClasses = opened
? 'dash-debug-menu dash-debug-menu--opened'
@@ -40,9 +40,7 @@ class DebugMenu extends Component {
const menuContent = opened ? (
{callbackGraphOpened ? (
-
+
) : null}
{error.frontEnd.length > 0 || error.backEnd.length > 0 ? (
@@ -152,7 +150,7 @@ class DebugMenu extends Component {
DebugMenu.propTypes = {
children: PropTypes.object,
error: PropTypes.object,
- dependenciesRequest: PropTypes.object,
+ graphs: PropTypes.object,
};
export {DebugMenu};
diff --git a/dash-renderer/src/persistence.js b/dash-renderer/src/persistence.js
index 2b97f66bf1..36b8688a3f 100644
--- a/dash-renderer/src/persistence.js
+++ b/dash-renderer/src/persistence.js
@@ -75,12 +75,7 @@ export const storePrefix = '_dash_persistence.';
function err(e) {
const error = typeof e === 'string' ? new Error(e) : e;
- // Send this to the console too, so it's still available with debug off
- /* eslint-disable-next-line no-console */
- console.error(e);
-
return createAction('ON_ERROR')({
- myID: storePrefix,
type: 'frontEnd',
error,
});
diff --git a/dash-renderer/src/reducers/dependencyGraph.js b/dash-renderer/src/reducers/dependencyGraph.js
index b023275ab4..cba95c2982 100644
--- a/dash-renderer/src/reducers/dependencyGraph.js
+++ b/dash-renderer/src/reducers/dependencyGraph.js
@@ -1,65 +1,10 @@
-import {type} from 'ramda';
-import {DepGraph} from 'dependency-graph';
-import {isMultiOutputProp, parseMultipleOutputs} from '../utils';
-
const initialGraph = {};
const graphs = (state = initialGraph, action) => {
- switch (action.type) {
- case 'COMPUTE_GRAPHS': {
- const dependencies = action.payload;
- const inputGraph = new DepGraph();
- const multiGraph = new DepGraph();
-
- dependencies.forEach(function registerDependency(dependency) {
- const {output, inputs} = dependency;
-
- // Multi output supported will be a string already
- // Backward compatibility by detecting object.
- let outputId;
- if (type(output) === 'Object') {
- outputId = `${output.id}.${output.property}`;
- } else {
- outputId = output;
- if (isMultiOutputProp(output)) {
- parseMultipleOutputs(output).forEach(out => {
- multiGraph.addNode(out);
- inputs.forEach(i => {
- const inputId = `${i.id}.${i.property}`;
- if (!multiGraph.hasNode(inputId)) {
- multiGraph.addNode(inputId);
- }
- multiGraph.addDependency(inputId, out);
- });
- });
- } else {
- multiGraph.addNode(output);
- inputs.forEach(i => {
- const inputId = `${i.id}.${i.property}`;
- if (!multiGraph.hasNode(inputId)) {
- multiGraph.addNode(inputId);
- }
- multiGraph.addDependency(inputId, output);
- });
- }
- }
-
- inputs.forEach(inputObject => {
- const inputId = `${inputObject.id}.${inputObject.property}`;
- inputGraph.addNode(outputId);
- if (!inputGraph.hasNode(inputId)) {
- inputGraph.addNode(inputId);
- }
- inputGraph.addDependency(inputId, outputId);
- });
- });
-
- return {InputGraph: inputGraph, MultiGraph: multiGraph};
- }
-
- default:
- return state;
+ if (action.type === 'SET_GRAPHS') {
+ return action.payload;
}
+ return state;
};
export default graphs;
diff --git a/dash-renderer/src/reducers/error.js b/dash-renderer/src/reducers/error.js
index 8c72a64eee..b796024e49 100644
--- a/dash-renderer/src/reducers/error.js
+++ b/dash-renderer/src/reducers/error.js
@@ -8,6 +8,11 @@ const initialError = {
export default function error(state = initialError, action) {
switch (action.type) {
case 'ON_ERROR': {
+ // log errors to the console for stack tracing and so they're
+ // available even with debugging off
+ /* eslint-disable-next-line no-console */
+ console.error(action.payload.error);
+
if (action.payload.type === 'frontEnd') {
return {
frontEnd: [
diff --git a/dash-renderer/src/reducers/paths.js b/dash-renderer/src/reducers/paths.js
index cf11c993f8..fd48700108 100644
--- a/dash-renderer/src/reducers/paths.js
+++ b/dash-renderer/src/reducers/paths.js
@@ -1,57 +1,12 @@
-import {crawlLayout, hasPropsId} from './utils';
-import {
- concat,
- equals,
- filter,
- isEmpty,
- isNil,
- keys,
- mergeRight,
- omit,
- slice,
-} from 'ramda';
import {getAction} from '../actions/constants';
-const initialPaths = null;
+const initialPaths = {strs: {}, objs: {}};
const paths = (state = initialPaths, action) => {
- switch (action.type) {
- case getAction('COMPUTE_PATHS'): {
- const {subTree, startingPath} = action.payload;
- let oldState = state;
- if (isNil(state)) {
- oldState = {};
- }
- let newState;
-
- // if we're updating a subtree, clear out all of the existing items
- if (!isEmpty(startingPath)) {
- const removeKeys = filter(
- k =>
- equals(
- startingPath,
- slice(0, startingPath.length, oldState[k])
- ),
- keys(oldState)
- );
- newState = omit(removeKeys, oldState);
- } else {
- newState = mergeRight({}, oldState);
- }
-
- crawlLayout(subTree, function assignPath(child, itempath) {
- if (hasPropsId(child)) {
- newState[child.props.id] = concat(startingPath, itempath);
- }
- });
-
- return newState;
- }
-
- default: {
- return state;
- }
+ if (action.type === getAction('SET_PATHS')) {
+ return action.payload;
}
+ return state;
};
export default paths;
diff --git a/dash-renderer/src/reducers/pendingCallbacks.js b/dash-renderer/src/reducers/pendingCallbacks.js
new file mode 100644
index 0000000000..70a2cd3f86
--- /dev/null
+++ b/dash-renderer/src/reducers/pendingCallbacks.js
@@ -0,0 +1,11 @@
+const pendingCallbacks = (state = [], action) => {
+ switch (action.type) {
+ case 'SET_PENDING_CALLBACKS':
+ return action.payload;
+
+ default:
+ return state;
+ }
+};
+
+export default pendingCallbacks;
diff --git a/dash-renderer/src/reducers/reducer.js b/dash-renderer/src/reducers/reducer.js
index 1a82bd1ecc..ffdb8794fa 100644
--- a/dash-renderer/src/reducers/reducer.js
+++ b/dash-renderer/src/reducers/reducer.js
@@ -1,18 +1,12 @@
-import {
- concat,
- equals,
- filter,
- forEach,
- isEmpty,
- keys,
- lensPath,
- view,
-} from 'ramda';
+import {forEach, isEmpty, keys, path} from 'ramda';
import {combineReducers} from 'redux';
+
+import {getCallbacksByInput} from '../actions/dependencies';
+
import layout from './layout';
import graphs from './dependencyGraph';
import paths from './paths';
-import requestQueue from './requestQueue';
+import pendingCallbacks from './pendingCallbacks';
import appLifecycle from './appLifecycle';
import history from './history';
import error from './error';
@@ -33,7 +27,7 @@ function mainReducer() {
layout,
graphs,
paths,
- requestQueue,
+ pendingCallbacks,
config,
history,
error,
@@ -48,22 +42,14 @@ function mainReducer() {
function getInputHistoryState(itempath, props, state) {
const {graphs, layout, paths} = state;
- const {InputGraph} = graphs;
- const keyObj = filter(equals(itempath), paths);
+ const idProps = path(itempath.concat(['props']), layout);
+ const {id} = idProps || {};
let historyEntry;
- if (!isEmpty(keyObj)) {
- const id = keys(keyObj)[0];
+ if (id) {
historyEntry = {id, props: {}};
keys(props).forEach(propKey => {
- const inputKey = `${id}.${propKey}`;
- if (
- InputGraph.hasNode(inputKey) &&
- InputGraph.dependenciesOf(inputKey).length > 0
- ) {
- historyEntry.props[propKey] = view(
- lensPath(concat(paths[id], ['props', propKey])),
- layout
- );
+ if (getCallbacksByInput(graphs, paths, id, propKey).length) {
+ historyEntry.props[propKey] = idProps[propKey];
}
});
}
diff --git a/dash-renderer/src/reducers/requestQueue.js b/dash-renderer/src/reducers/requestQueue.js
deleted file mode 100644
index 995285a91d..0000000000
--- a/dash-renderer/src/reducers/requestQueue.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import {clone} from 'ramda';
-
-const requestQueue = (state = [], action) => {
- switch (action.type) {
- case 'SET_REQUEST_QUEUE':
- return clone(action.payload);
-
- default:
- return state;
- }
-};
-
-export default requestQueue;
diff --git a/dash-renderer/src/reducers/utils.js b/dash-renderer/src/reducers/utils.js
deleted file mode 100644
index e753389f11..0000000000
--- a/dash-renderer/src/reducers/utils.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import {
- allPass,
- append,
- compose,
- flip,
- has,
- is,
- prop,
- reduce,
- type,
-} from 'ramda';
-
-const extend = reduce(flip(append));
-
-const hasProps = allPass([is(Object), has('props')]);
-
-export const hasPropsId = allPass([
- hasProps,
- compose(has('id'), prop('props')),
-]);
-
-export const hasPropsChildren = allPass([
- hasProps,
- compose(has('children'), prop('props')),
-]);
-
-// crawl a layout object, apply a function on every object
-export const crawlLayout = (object, func, path = []) => {
- func(object, path);
-
- /*
- * object may be a string, a number, or null
- * R.has will return false for both of those types
- */
- if (hasPropsChildren(object)) {
- const newPath = extend(path, ['props', 'children']);
- if (Array.isArray(object.props.children)) {
- object.props.children.forEach((child, i) => {
- crawlLayout(child, func, append(i, newPath));
- });
- } else {
- crawlLayout(object.props.children, func, newPath);
- }
- } else if (is(Array, object)) {
- /*
- * Sometimes when we're updating a sub-tree
- * (like when we're responding to a callback)
- * that returns `{children: [{...}, {...}]}`
- * then we'll need to start crawling from
- * an array instead of an object.
- */
-
- object.forEach((child, i) => {
- crawlLayout(child, func, append(i, path));
- });
- }
-};
-
-export function hasId(child) {
- return (
- type(child) === 'Object' &&
- has('props', child) &&
- has('id', child.props)
- );
-}
diff --git a/dash-renderer/src/utils.js b/dash-renderer/src/utils.js
deleted file mode 100644
index 623cfcb335..0000000000
--- a/dash-renderer/src/utils.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import {has, type} from 'ramda';
-
-/*
- * requests_pathname_prefix is the new config parameter introduced in
- * dash==0.18.0. The previous versions just had url_base_pathname
- */
-export function urlBase(config) {
- const hasUrlBase = has('url_base_pathname', config);
- const hasReqPrefix = has('requests_pathname_prefix', config);
- if (type(config) !== 'Object' || (!hasUrlBase && !hasReqPrefix)) {
- throw new Error(
- `
- Trying to make an API request but neither
- "url_base_pathname" nor "requests_pathname_prefix"
- is in \`config\`. \`config\` is: `,
- config
- );
- }
-
- const base = hasReqPrefix
- ? config.requests_pathname_prefix
- : config.url_base_pathname;
-
- return base.charAt(base.length - 1) === '/' ? base : base + '/';
-}
-
-export function uid() {
- function s4() {
- const h = 0x10000;
- return Math.floor((1 + Math.random()) * h)
- .toString(16)
- .substring(1);
- }
- return (
- s4() +
- s4() +
- '-' +
- s4() +
- '-' +
- s4() +
- '-' +
- s4() +
- '-' +
- s4() +
- s4() +
- s4()
- );
-}
-
-export function isMultiOutputProp(outputIdAndProp) {
- /*
- * If this update is for multiple outputs, then it has
- * starting & trailing `..` and each propId pair is separated
- * by `...`, e.g.
- * "..output-1.value...output-2.value...output-3.value...output-4.value.."
- */
-
- return outputIdAndProp.startsWith('..');
-}
-
-export function parseMultipleOutputs(outputIdAndProp) {
- /*
- * If this update is for multiple outputs, then it has
- * starting & trailing `..` and each propId pair is separated
- * by `...`, e.g.
- * "..output-1.value...output-2.value...output-3.value...output-4.value.."
- */
- return outputIdAndProp.split('...').map(o => o.replace('..', ''));
-}
diff --git a/dash-renderer/tests/isAppReady.test.js b/dash-renderer/tests/isAppReady.test.js
index a85ea81240..a16cee04da 100644
--- a/dash-renderer/tests/isAppReady.test.js
+++ b/dash-renderer/tests/isAppReady.test.js
@@ -1,4 +1,5 @@
import isAppReady from "../src/actions/isAppReady";
+import {EventEmitter} from "../src/actions/utils";
const WAIT = 1000;
@@ -15,11 +16,13 @@ describe('isAppReady', () => {
};
});
+ const emitter = new EventEmitter();
+
it('executes if app is ready', async () => {
let done = false;
Promise.resolve(isAppReady(
[{ namespace: '__components', type: 'b', props: { id: 'comp1' } }],
- { comp1: [0] },
+ { strs: { comp1: [0] }, objs: {}, events: emitter },
['comp1']
)).then(() => {
done = true
@@ -33,7 +36,7 @@ describe('isAppReady', () => {
let done = false;
Promise.resolve(isAppReady(
[{ namespace: '__components', type: 'a', props: { id: 'comp1' } }],
- { comp1: [0] },
+ { strs: { comp1: [0] }, objs: {}, events: emitter },
['comp1']
)).then(() => {
done = true
@@ -47,4 +50,4 @@ describe('isAppReady', () => {
await new Promise(r => setTimeout(r, WAIT));
expect(done).toEqual(true);
});
-});
\ No newline at end of file
+});
diff --git a/dash-renderer/tests/persistence.test.js b/dash-renderer/tests/persistence.test.js
index 3357af6fe7..cbaa752b65 100644
--- a/dash-renderer/tests/persistence.test.js
+++ b/dash-renderer/tests/persistence.test.js
@@ -65,8 +65,6 @@ const layoutA = storeType => ({
describe('storage fallbacks and equivalence', () => {
const propVal = 42;
const propStr = String(propVal);
- let originalConsoleErr;
- let consoleCalls;
let dispatchCalls;
const _dispatch = evt => {
@@ -87,11 +85,6 @@ describe('storage fallbacks and equivalence', () => {
};
dispatchCalls = [];
- consoleCalls = [];
- originalConsoleErr = console.error;
- console.error = msg => {
- consoleCalls.push(msg);
- };
clearStores();
});
@@ -99,7 +92,6 @@ describe('storage fallbacks and equivalence', () => {
afterEach(() => {
delete window.my_components;
clearStores();
- console.error = originalConsoleErr;
});
['local', 'session'].forEach(storeType => {
@@ -111,7 +103,6 @@ describe('storage fallbacks and equivalence', () => {
test(`empty ${storeName} works`, () => {
recordUiEdit(layout, {p1: propVal}, _dispatch);
expect(dispatchCalls).toEqual([]);
- expect(consoleCalls).toEqual([]);
expect(store.getItem(`${storePrefix}a.p1.true`)).toBe(`[${propStr}]`);
});
@@ -123,7 +114,6 @@ describe('storage fallbacks and equivalence', () => {
`${storeName} init first try failed; clearing and retrying`,
`${storeName} init set/get succeeded after clearing!`
]);
- expect(consoleCalls).toEqual(dispatchCalls);
expect(store.getItem(`${storePrefix}a.p1.true`)).toBe(`[${propStr}]`);
// Boolean so we don't see the very long value if test fails
const x = Boolean(store.getItem(`${storePrefix}x.x`));
@@ -138,7 +128,6 @@ describe('storage fallbacks and equivalence', () => {
`${storeName} init first try failed; clearing and retrying`,
`${storeName} init still failed, falling back to memory`
]);
- expect(consoleCalls).toEqual(dispatchCalls);
expect(stores.memory.getItem('a.p1.true')).toEqual([propVal]);
const x = Boolean(store.getItem('not_ours'));
expect(x).toBe(true);
@@ -150,14 +139,12 @@ describe('storage fallbacks and equivalence', () => {
// initialize and ensure the store is happy
recordUiEdit(layout, {p1: propVal}, _dispatch);
expect(dispatchCalls).toEqual([]);
- expect(consoleCalls).toEqual([]);
// now flood it.
recordUiEdit(layout, {p1: longString(26)}, _dispatch);
expect(dispatchCalls).toEqual([
`a.p1.true failed to save in ${storeName}. Persisted props may be lost.`
]);
- expect(consoleCalls).toEqual(dispatchCalls);
});
});
diff --git a/dash/__init__.py b/dash/__init__.py
index ead91983cd..647c457edd 100644
--- a/dash/__init__.py
+++ b/dash/__init__.py
@@ -4,6 +4,4 @@
from . import exceptions # noqa: F401
from . import resources # noqa: F401
from .version import __version__ # noqa: F401
-from ._callback_context import CallbackContext as _CallbackContext
-
-callback_context = _CallbackContext()
+from ._callback_context import callback_context # noqa: F401
diff --git a/dash/_callback_context.py b/dash/_callback_context.py
index dbd55c241c..79e37ebc56 100644
--- a/dash/_callback_context.py
+++ b/dash/_callback_context.py
@@ -18,6 +18,19 @@ def assert_context(*args, **kwargs):
return assert_context
+class FalsyList(list):
+ def __bool__(self):
+ # for Python 3
+ return False
+
+ def __nonzero__(self):
+ # for Python 2
+ return False
+
+
+falsy_triggered = FalsyList([{"prop_id": ".", "value": None}])
+
+
# pylint: disable=no-init
class CallbackContext:
@property
@@ -33,9 +46,31 @@ def states(self):
@property
@has_context
def triggered(self):
- return getattr(flask.g, "triggered_inputs", [])
+ # For backward compatibility: previously `triggered` always had a
+ # value - to avoid breaking existing apps, add a dummy item but
+ # make the list still look falsy. So `if ctx.triggered` will make it
+ # look empty, but you can still do `triggered[0]["prop_id"].split(".")`
+ return getattr(flask.g, "triggered_inputs", []) or falsy_triggered
+
+ @property
+ @has_context
+ def outputs_list(self):
+ return getattr(flask.g, "outputs_list", [])
+
+ @property
+ @has_context
+ def inputs_list(self):
+ return getattr(flask.g, "inputs_list", [])
+
+ @property
+ @has_context
+ def states_list(self):
+ return getattr(flask.g, "states_list", [])
@property
@has_context
def response(self):
return getattr(flask.g, "dash_response")
+
+
+callback_context = CallbackContext()
diff --git a/dash/_utils.py b/dash/_utils.py
index dd95636b29..51c476c9c3 100644
--- a/dash/_utils.py
+++ b/dash/_utils.py
@@ -7,13 +7,18 @@
import collections
import subprocess
import logging
-from io import open # pylint: disable=redefined-builtin
+import io
+import json
from functools import wraps
import future.utils as utils
from . import exceptions
logger = logging.getLogger()
+# py2/3 json.dumps-compatible strings - these are equivalent in py3, not in py2
+# note because we import unicode_literals u"" and "" are both unicode
+_strings = (type(""), type(utils.bytes_to_native_str(b"")))
+
def interpolate_str(template, **data):
s = template
@@ -155,11 +160,54 @@ def create_callback_id(output):
if isinstance(output, (list, tuple)):
return "..{}..".format(
"...".join(
- "{}.{}".format(x.component_id, x.component_property) for x in output
+ "{}.{}".format(
+ # A single dot within a dict id key or value is OK
+ # but in case of multiple dots together escape each dot
+ # with `\` so we don't mistake it for multi-outputs
+ x.component_id_str().replace(".", "\\."),
+ x.component_property,
+ )
+ for x in output
)
)
- return "{}.{}".format(output.component_id, output.component_property)
+ return "{}.{}".format(
+ output.component_id_str().replace(".", "\\."), output.component_property
+ )
+
+
+# inverse of create_callback_id - should only be relevant if an old renderer is
+# hooked up to a new back end, which will only happen in special cases like
+# embedded
+def split_callback_id(callback_id):
+ if callback_id.startswith(".."):
+ return [split_callback_id(oi) for oi in callback_id[2:-2].split("...")]
+
+ id_, prop = callback_id.rsplit(".", 1)
+ return {"id": id_, "property": prop}
+
+
+def stringify_id(id_):
+ if isinstance(id_, dict):
+ return json.dumps(id_, sort_keys=True, separators=(",", ":"))
+ return id_
+
+
+def inputs_to_dict(inputs_list):
+ inputs = {}
+ for i in inputs_list:
+ inputsi = i if isinstance(i, list) else [i]
+ for ii in inputsi:
+ id_str = stringify_id(ii["id"])
+ inputs["{}.{}".format(id_str, ii["property"])] = ii.get("value")
+ return inputs
+
+
+def inputs_to_vals(inputs):
+ return [
+ [ii.get("value") for ii in i] if isinstance(i, list) else i.get("value")
+ for i in inputs
+ ]
def run_command_with_process(cmd):
@@ -177,7 +225,7 @@ def run_command_with_process(cmd):
def compute_md5(path):
- with open(path, encoding="utf-8") as fp:
+ with io.open(path, encoding="utf-8") as fp:
return hashlib.md5(fp.read().encode("utf-8")).hexdigest()
diff --git a/dash/_validate.py b/dash/_validate.py
new file mode 100644
index 0000000000..98ef6de530
--- /dev/null
+++ b/dash/_validate.py
@@ -0,0 +1,350 @@
+import collections
+import re
+
+from .development.base_component import Component
+from .dependencies import Input, Output, State
+from . import exceptions
+from ._utils import patch_collections_abc, _strings, stringify_id
+
+
+def validate_callback(output, inputs, state):
+ is_multi = isinstance(output, (list, tuple))
+
+ outputs = output if is_multi else [output]
+
+ for args, cls in [(outputs, Output), (inputs, Input), (state, State)]:
+ validate_callback_args(args, cls)
+
+
+def validate_callback_args(args, cls):
+ name = cls.__name__
+ if not isinstance(args, (list, tuple)):
+ raise exceptions.IncorrectTypeException(
+ """
+ The {} argument `{}` must be a list or tuple of
+ `dash.dependencies.{}`s.
+ """.format(
+ name.lower(), str(args), name
+ )
+ )
+
+ for arg in args:
+ if not isinstance(arg, cls):
+ raise exceptions.IncorrectTypeException(
+ """
+ The {} argument `{}` must be of type `dash.dependencies.{}`.
+ """.format(
+ name.lower(), str(arg), name
+ )
+ )
+
+ if not isinstance(getattr(arg, "component_property", None), _strings):
+ raise exceptions.IncorrectTypeException(
+ """
+ component_property must be a string, found {!r}
+ """.format(
+ arg.component_property
+ )
+ )
+
+ if hasattr(arg, "component_event"):
+ raise exceptions.NonExistentEventException(
+ """
+ Events have been removed.
+ Use the associated property instead.
+ """
+ )
+
+ if isinstance(arg.component_id, dict):
+ validate_id_dict(arg)
+
+ elif isinstance(arg.component_id, _strings):
+ validate_id_string(arg)
+
+ else:
+ raise exceptions.IncorrectTypeException(
+ """
+ component_id must be a string or dict, found {!r}
+ """.format(
+ arg.component_id
+ )
+ )
+
+
+def validate_id_dict(arg):
+ arg_id = arg.component_id
+
+ for k in arg_id:
+ # Need to keep key type validation on the Python side, since
+ # non-string keys will be converted to strings in json.dumps and may
+ # cause unwanted collisions
+ if not isinstance(k, _strings):
+ raise exceptions.IncorrectTypeException(
+ """
+ Wildcard ID keys must be non-empty strings,
+ found {!r} in id {!r}
+ """.format(
+ k, arg_id
+ )
+ )
+
+
+def validate_id_string(arg):
+ arg_id = arg.component_id
+
+ invalid_chars = ".{"
+ invalid_found = [x for x in invalid_chars if x in arg_id]
+ if invalid_found:
+ raise exceptions.InvalidComponentIdError(
+ """
+ The element `{}` contains `{}` in its ID.
+ Characters `{}` are not allowed in IDs.
+ """.format(
+ arg_id, "`, `".join(invalid_found), "`, `".join(invalid_chars)
+ )
+ )
+
+
+def validate_multi_return(outputs_list, output_value, callback_id):
+ if not isinstance(output_value, (list, tuple)):
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ The callback {} is a multi-output.
+ Expected the output type to be a list or tuple but got:
+ {}.
+ """.format(
+ callback_id, repr(output_value)
+ )
+ )
+
+ if len(output_value) != len(outputs_list):
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ Invalid number of output values for {}.
+ Expected {}, got {}
+ """.format(
+ callback_id, len(outputs_list), len(output_value)
+ )
+ )
+
+ for i, outi in enumerate(outputs_list):
+ if isinstance(outi, list):
+ vi = output_value[i]
+ if not isinstance(vi, (list, tuple)):
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ The callback {} ouput {} is a wildcard multi-output.
+ Expected the output type to be a list or tuple but got:
+ {}.
+ output spec: {}
+ """.format(
+ callback_id, i, repr(vi), repr(outi)
+ )
+ )
+
+ if len(vi) != len(outi):
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ Invalid number of output values for {} item {}.
+ Expected {}, got {}
+ output spec: {}
+ output value: {}
+ """.format(
+ callback_id, i, len(vi), len(outi), repr(outi), repr(vi)
+ )
+ )
+
+
+def fail_callback_output(output_value, output):
+ valid = _strings + (dict, int, float, type(None), Component)
+
+ def _raise_invalid(bad_val, outer_val, path, index=None, toplevel=False):
+ bad_type = type(bad_val).__name__
+ outer_id = (
+ "(id={:s})".format(outer_val.id) if getattr(outer_val, "id", False) else ""
+ )
+ outer_type = type(outer_val).__name__
+ if toplevel:
+ location = """
+ The value in question is either the only value returned,
+ or is in the top level of the returned list,
+ """
+ else:
+ index_string = "[*]" if index is None else "[{:d}]".format(index)
+ location = """
+ The value in question is located at
+ {} {} {}
+ {},
+ """.format(
+ index_string, outer_type, outer_id, path
+ )
+
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ The callback for `{output}`
+ returned a {object:s} having type `{type}`
+ which is not JSON serializable.
+
+ {location}
+ and has string representation
+ `{bad_val}`
+
+ In general, Dash properties can only be
+ dash components, strings, dictionaries, numbers, None,
+ or lists of those.
+ """.format(
+ output=repr(output),
+ object="tree with one value" if not toplevel else "value",
+ type=bad_type,
+ location=location,
+ bad_val=bad_val,
+ )
+ )
+
+ def _value_is_valid(val):
+ return isinstance(val, valid)
+
+ def _validate_value(val, index=None):
+ # val is a Component
+ if isinstance(val, Component):
+ # pylint: disable=protected-access
+ for p, j in val._traverse_with_paths():
+ # check each component value in the tree
+ if not _value_is_valid(j):
+ _raise_invalid(bad_val=j, outer_val=val, path=p, index=index)
+
+ # Children that are not of type Component or
+ # list/tuple not returned by traverse
+ child = getattr(j, "children", None)
+ if not isinstance(child, (tuple, collections.MutableSequence)):
+ if child and not _value_is_valid(child):
+ _raise_invalid(
+ bad_val=child,
+ outer_val=val,
+ path=p + "\n" + "[*] " + type(child).__name__,
+ index=index,
+ )
+
+ # Also check the child of val, as it will not be returned
+ child = getattr(val, "children", None)
+ if not isinstance(child, (tuple, collections.MutableSequence)):
+ if child and not _value_is_valid(child):
+ _raise_invalid(
+ bad_val=child,
+ outer_val=val,
+ path=type(child).__name__,
+ index=index,
+ )
+
+ # val is not a Component, but is at the top level of tree
+ elif not _value_is_valid(val):
+ _raise_invalid(
+ bad_val=val,
+ outer_val=type(val).__name__,
+ path="",
+ index=index,
+ toplevel=True,
+ )
+
+ if isinstance(output_value, list):
+ for i, val in enumerate(output_value):
+ _validate_value(val, index=i)
+ else:
+ _validate_value(output_value)
+
+ # if we got this far, raise a generic JSON error
+ raise exceptions.InvalidCallbackReturnValue(
+ """
+ The callback for property `{property:s}` of component `{id:s}`
+ returned a value which is not JSON serializable.
+
+ In general, Dash properties can only be dash components, strings,
+ dictionaries, numbers, None, or lists of those.
+ """.format(
+ property=output.component_property, id=output.component_id
+ )
+ )
+
+
+def check_obsolete(kwargs):
+ for key in kwargs:
+ if key in ["components_cache_max_age", "static_folder"]:
+ raise exceptions.ObsoleteKwargException(
+ """
+ {} is no longer a valid keyword argument in Dash since v1.0.
+ See https://dash.plotly.com for details.
+ """.format(
+ key
+ )
+ )
+ # any other kwarg mimic the built-in exception
+ raise TypeError("Dash() got an unexpected keyword argument '" + key + "'")
+
+
+def validate_js_path(registered_paths, package_name, path_in_package_dist):
+ if package_name not in registered_paths:
+ raise exceptions.DependencyException(
+ """
+ Error loading dependency. "{}" is not a registered library.
+ Registered libraries are:
+ {}
+ """.format(
+ package_name, list(registered_paths.keys())
+ )
+ )
+
+ if path_in_package_dist not in registered_paths[package_name]:
+ raise exceptions.DependencyException(
+ """
+ "{}" is registered but the path requested is not valid.
+ The path requested: "{}"
+ List of registered paths: {}
+ """.format(
+ package_name, path_in_package_dist, registered_paths
+ )
+ )
+
+
+def validate_index(name, checks, index):
+ missing = [i for check, i in checks if not re.compile(check).search(index)]
+ if missing:
+ plural = "s" if len(missing) > 1 else ""
+ raise exceptions.InvalidIndexException(
+ "Missing item{pl} {items} in {name}.".format(
+ items=", ".join(missing), pl=plural, name=name
+ )
+ )
+
+
+def validate_layout_type(value):
+ if not isinstance(value, (Component, patch_collections_abc("Callable"))):
+ raise exceptions.NoLayoutException(
+ "Layout must be a dash component "
+ "or a function that returns a dash component."
+ )
+
+
+def validate_layout(layout, layout_value):
+ if layout is None:
+ raise exceptions.NoLayoutException(
+ """
+ The layout was `None` at the time that `run_server` was called.
+ Make sure to set the `layout` attribute of your application
+ before running the server.
+ """
+ )
+
+ layout_id = stringify_id(getattr(layout_value, "id", None))
+
+ component_ids = {layout_id} if layout_id else set()
+ for component in layout_value._traverse(): # pylint: disable=protected-access
+ component_id = stringify_id(getattr(component, "id", None))
+ if component_id and component_id in component_ids:
+ raise exceptions.DuplicateIdError(
+ """
+ Duplicate component id found in the initial layout: `{}`
+ """.format(
+ component_id
+ )
+ )
+ component_ids.add(component_id)
diff --git a/dash/_watch.py b/dash/_watch.py
index 34c523478c..65c87e284a 100644
--- a/dash/_watch.py
+++ b/dash/_watch.py
@@ -11,7 +11,7 @@ def watch(folders, on_change, pattern=None, sleep_time=0.1):
def walk():
walked = []
for folder in folders:
- for current, _, files, in os.walk(folder):
+ for current, _, files in os.walk(folder):
for f in files:
if pattern and not pattern.search(f):
continue
diff --git a/dash/dash.py b/dash/dash.py
index a90c732867..09b0ae61eb 100644
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -11,10 +11,8 @@
import threading
import re
import logging
-import pprint
from functools import wraps
-from textwrap import dedent
import flask
from flask_compress import Compress
@@ -23,23 +21,29 @@
import plotly
import dash_renderer
-from .dependencies import Input, Output, State
from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
-from .development.base_component import Component, ComponentRegistry
-from . import exceptions
-from ._utils import AttributeDict as _AttributeDict
-from ._utils import interpolate_str as _interpolate
-from ._utils import format_tag as _format_tag
-from ._utils import generate_hash as _generate_hash
-from ._utils import patch_collections_abc as _patch_collections_abc
-from . import _watch
-from ._utils import get_asset_path as _get_asset_path
-from ._utils import create_callback_id as _create_callback_id
-from ._utils import get_relative_path as _get_relative_path
-from ._utils import strip_relative_path as _strip_relative_path
-from ._configs import get_combined_config, pathname_configs
+from .development.base_component import ComponentRegistry
+from .exceptions import PreventUpdate, InvalidResourceError
from .version import __version__
+from ._configs import get_combined_config, pathname_configs
+from ._utils import (
+ AttributeDict,
+ create_callback_id,
+ format_tag,
+ generate_hash,
+ get_asset_path,
+ get_relative_path,
+ inputs_to_dict,
+ inputs_to_vals,
+ interpolate_str,
+ patch_collections_abc,
+ split_callback_id,
+ stringify_id,
+ strip_relative_path,
+)
+from . import _validate
+from . import _watch
_default_index = """
@@ -67,15 +71,14 @@
"""
-_re_index_entry = re.compile(r"{%app_entry%}")
-_re_index_config = re.compile(r"{%config%}")
-_re_index_scripts = re.compile(r"{%scripts%}")
-_re_renderer_scripts = re.compile(r"{%renderer%}")
+_re_index_entry = "{%app_entry%}", "{%app_entry%}"
+_re_index_config = "{%config%}", "{%config%}"
+_re_index_scripts = "{%scripts%}", "{%scripts%}"
-_re_index_entry_id = re.compile(r'id="react-entry-point"')
-_re_index_config_id = re.compile(r'id="_dash-config"')
-_re_index_scripts_id = re.compile(r'src=".*dash[-_]renderer.*"')
-_re_renderer_scripts_id = re.compile(r'id="_dash-renderer')
+_re_index_entry_id = 'id="react-entry-point"', "#react-entry-point"
+_re_index_config_id = 'id="_dash-config"', "#_dash-config"
+_re_index_scripts_id = 'src="[^"]*dash[-_]renderer[^"]*"', "dash-renderer"
+_re_renderer_scripts_id = 'id="_dash-renderer', "new DashRenderer"
class _NoUpdate(object):
@@ -87,6 +90,13 @@ class _NoUpdate(object):
no_update = _NoUpdate()
+_inline_clientside_template = """
+var clientside = window.dash_clientside = window.dash_clientside || {{}};
+var ns = clientside["{namespace}"] = clientside["{namespace}"] || {{}};
+ns["{function_name}"] = {clientside_function};
+"""
+
+
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments, too-many-locals
class Dash(object):
@@ -231,14 +241,7 @@ def __init__(
plugins=None,
**obsolete
):
- for key in obsolete:
- if key in ["components_cache_max_age", "static_folder"]:
- raise exceptions.ObsoleteKwargException(
- key + " is no longer a valid keyword argument in Dash "
- "since v1.0. See https://dash.plotly.com for details."
- )
- # any other kwarg mimic the built-in exception
- raise TypeError("Dash() got an unexpected keyword argument '" + key + "'")
+ _validate.check_obsolete(obsolete)
# 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)
@@ -256,7 +259,7 @@ def __init__(
url_base_pathname, routes_pathname_prefix, requests_pathname_prefix
)
- self.config = _AttributeDict(
+ self.config = AttributeDict(
name=name,
assets_folder=os.path.join(
flask.helpers.get_root_path(name), assets_folder
@@ -279,7 +282,7 @@ def __init__(
external_scripts=external_scripts or [],
external_stylesheets=external_stylesheets or [],
suppress_callback_exceptions=get_combined_config(
- "suppress_callback_exceptions", suppress_callback_exceptions, False,
+ "suppress_callback_exceptions", suppress_callback_exceptions, False
),
show_undo_redo=show_undo_redo,
)
@@ -302,8 +305,10 @@ def __init__(
"via the Dash constructor"
)
- # list of dependencies
+ # list of dependencies - this one is used by the back end for dispatching
self.callback_map = {}
+ # same deps as a list to catch duplicate outputs, and to send to the front end
+ self._callback_list = []
# list of inline scripts
self._inline_scripts = []
@@ -329,7 +334,7 @@ def __init__(
self._cached_layout = None
self._setup_dev_tools()
- self._hot_reload = _AttributeDict(
+ self._hot_reload = AttributeDict(
hash=None,
hard=False,
lock=threading.RLock(),
@@ -342,7 +347,7 @@ def __init__(
self.logger = logging.getLogger(name)
self.logger.addHandler(logging.StreamHandler(stream=sys.stdout))
- if isinstance(plugins, _patch_collections_abc("Iterable")):
+ if isinstance(plugins, patch_collections_abc("Iterable")):
for plugin in plugins:
plugin.plug(self)
@@ -376,63 +381,47 @@ def init_app(self, app=None):
# gzip
Compress(self.server)
- @self.server.errorhandler(exceptions.PreventUpdate)
+ @self.server.errorhandler(PreventUpdate)
def _handle_error(_):
"""Handle a halted callback and return an empty 204 response."""
return "", 204
- prefix = config.routes_pathname_prefix
-
self.server.before_first_request(self._setup_server)
# add a handler for components suites errors to return 404
- self.server.errorhandler(exceptions.InvalidResourceError)(
- self._invalid_resources_handler
- )
-
- self._add_url("{}_dash-layout".format(prefix), self.serve_layout)
-
- self._add_url("{}_dash-dependencies".format(prefix), self.dependencies)
+ self.server.errorhandler(InvalidResourceError)(self._invalid_resources_handler)
self._add_url(
- "{}_dash-update-component".format(prefix), self.dispatch, ["POST"]
- )
-
- self._add_url(
- (
- "{}_dash-component-suites"
- "/
"
- "/"
- ).format(prefix),
+ "_dash-component-suites//",
self.serve_component_suites,
)
-
- self._add_url("{}_dash-routes".format(prefix), self.serve_routes)
-
- self._add_url(prefix, self.index)
-
- self._add_url("{}_reload-hash".format(prefix), self.serve_reload_hash)
+ self._add_url("_dash-layout", self.serve_layout)
+ self._add_url("_dash-dependencies", self.dependencies)
+ self._add_url("_dash-update-component", self.dispatch, ["POST"])
+ self._add_url("_reload-hash", self.serve_reload_hash)
+ self._add_url("_favicon.ico", self._serve_default_favicon)
+ self._add_url("", self.index)
# catch-all for front-end routes, used by dcc.Location
- self._add_url("{}".format(prefix), self.index)
-
- self._add_url("{}_favicon.ico".format(prefix), self._serve_default_favicon)
+ self._add_url("", self.index)
def _add_url(self, name, view_func, methods=("GET",)):
+ full_name = self.config.routes_pathname_prefix + name
+
self.server.add_url_rule(
- name, view_func=view_func, endpoint=name, methods=list(methods)
+ full_name, view_func=view_func, endpoint=full_name, methods=list(methods)
)
# record the url in Dash.routes so that it can be accessed later
# e.g. for adding authentication with flask_login
- self.routes.append(name)
+ self.routes.append(full_name)
@property
def layout(self):
return self._layout
def _layout_value(self):
- if isinstance(self._layout, _patch_collections_abc("Callable")):
+ if isinstance(self._layout, patch_collections_abc("Callable")):
self._cached_layout = self._layout()
else:
self._cached_layout = self._layout
@@ -440,15 +429,7 @@ def _layout_value(self):
@layout.setter
def layout(self, value):
- if not isinstance(value, Component) and not isinstance(
- value, _patch_collections_abc("Callable")
- ):
- raise exceptions.NoLayoutException(
- "Layout must be a dash component "
- "or a function that returns "
- "a dash component."
- )
-
+ _validate.validate_layout_type(value)
self._cached_layout = None
self._layout = value
@@ -458,18 +439,8 @@ def index_string(self):
@index_string.setter
def index_string(self, value):
- checks = (
- (_re_index_entry.search(value), "app_entry"),
- (_re_index_config.search(value), "config"),
- (_re_index_scripts.search(value), "scripts"),
- )
- missing = [missing for check, missing in checks if not check]
- if missing:
- raise exceptions.InvalidIndexException(
- "Did you forget to include {} in your index string ?".format(
- ", ".join("{%" + x + "%}" for x in missing)
- )
- )
+ checks = (_re_index_entry, _re_index_config, _re_index_scripts)
+ _validate.validate_index("index string", checks, value)
self._index_string = value
def serve_layout(self):
@@ -489,6 +460,7 @@ def _config(self):
"ui": self._dev_tools.ui,
"props_check": self._dev_tools.props_check,
"show_undo_redo": self.config.show_undo_redo,
+ "suppress_callback_exceptions": self.config.suppress_callback_exceptions,
}
if self._dev_tools.hot_reload:
config["hot_reload"] = {
@@ -516,12 +488,6 @@ def serve_reload_hash(self):
}
)
- def serve_routes(self):
- return flask.Response(
- json.dumps(self.routes, cls=plotly.utils.PlotlyJSONEncoder),
- mimetype="application/json",
- )
-
def _collect_and_register_resources(self, resources):
# now needs the app context.
# template in the necessary component suite JS bundles
@@ -530,7 +496,7 @@ def _collect_and_register_resources(self, resources):
def _relative_url_path(relative_package_path="", namespace=""):
module_path = os.path.join(
- os.path.dirname(sys.modules[namespace].__file__), relative_package_path,
+ os.path.dirname(sys.modules[namespace].__file__), relative_package_path
)
modified = int(os.stat(module_path).st_mtime)
@@ -584,7 +550,7 @@ def _generate_css_dist_html(self):
return "\n".join(
[
- _format_tag("link", link, opened=True)
+ format_tag("link", link, opened=True)
if isinstance(link, dict)
else ''.format(link)
for link in (external_links + links)
@@ -626,7 +592,7 @@ def _generate_scripts_html(self):
return "\n".join(
[
- _format_tag("script", src)
+ format_tag("script", src)
if isinstance(src, dict)
else ''.format(src)
for src in srcs
@@ -659,31 +625,15 @@ def _generate_meta_html(self):
if not has_charset:
tags.append('')
- tags += [_format_tag("meta", x, opened=True) for x in meta_tags]
+ tags += [format_tag("meta", x, opened=True) for x in meta_tags]
return "\n ".join(tags)
# Serve the JS bundles for each package
- def serve_component_suites(self, package_name, path_in_package_dist):
- path_in_package_dist, has_fingerprint = check_fingerprint(path_in_package_dist)
-
- if package_name not in self.registered_paths:
- raise exceptions.DependencyException(
- "Error loading dependency.\n"
- '"{}" is not a registered library.\n'
- "Registered libraries are: {}".format(
- package_name, list(self.registered_paths.keys())
- )
- )
+ def serve_component_suites(self, package_name, fingerprinted_path):
+ path_in_pkg, has_fingerprint = check_fingerprint(fingerprinted_path)
- if path_in_package_dist not in self.registered_paths[package_name]:
- raise exceptions.DependencyException(
- '"{}" is registered but the path requested is not valid.\n'
- 'The path requested: "{}"\n'
- "List of registered paths: {}".format(
- package_name, path_in_package_dist, self.registered_paths
- )
- )
+ _validate.validate_js_path(self.registered_paths, package_name, path_in_pkg)
mimetype = (
{
@@ -691,19 +641,19 @@ def serve_component_suites(self, package_name, path_in_package_dist):
"css": "text/css",
"map": "application/json",
}
- )[path_in_package_dist.split(".")[-1]]
+ )[path_in_pkg.split(".")[-1]]
package = sys.modules[package_name]
self.logger.debug(
"serving -- package: %s[%s] resource: %s => location: %s",
package_name,
package.__version__,
- path_in_package_dist,
+ path_in_pkg,
package.__path__,
)
response = flask.Response(
- pkgutil.get_data(package_name, path_in_package_dist), mimetype=mimetype,
+ pkgutil.get_data(package_name, path_in_pkg), mimetype=mimetype
)
if has_fingerprint:
@@ -743,7 +693,7 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument
self.config.requests_pathname_prefix, __version__
)
- favicon = _format_tag(
+ favicon = format_tag(
"link",
{"rel": "icon", "type": "image/x-icon", "href": favicon_url},
opened=True,
@@ -761,21 +711,12 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument
)
checks = (
- (_re_index_entry_id.search(index), "#react-entry-point"),
- (_re_index_config_id.search(index), "#_dash-configs"),
- (_re_index_scripts_id.search(index), "dash-renderer"),
- (_re_renderer_scripts_id.search(index), "new DashRenderer"),
+ _re_index_entry_id,
+ _re_index_config_id,
+ _re_index_scripts_id,
+ _re_renderer_scripts_id,
)
- missing = [missing for check, missing in checks if not check]
-
- if missing:
- plural = "s" if len(missing) > 1 else ""
- raise exceptions.InvalidIndexException(
- "Missing element{pl} {ids} in index.".format(
- ids=", ".join(missing), pl=plural
- )
- )
-
+ _validate.validate_index("index", checks, index)
return index
def interpolate_index(
@@ -824,7 +765,7 @@ def interpolate_index(self, **kwargs):
:param favicon: A favicon tag if found in assets folder.
:return: The interpolated HTML string for the index.
"""
- return _interpolate(
+ return interpolate_str(
self.index_string,
metas=metas,
title=title,
@@ -837,329 +778,30 @@ def interpolate_index(self, **kwargs):
)
def dependencies(self):
- return flask.jsonify(
- [
- {
- "output": k,
- "inputs": v["inputs"],
- "state": v["state"],
- "clientside_function": v.get("clientside_function", None),
- }
- for k, v in self.callback_map.items()
- ]
- )
-
- def _validate_callback(self, output, inputs, state):
- # pylint: disable=too-many-branches
- layout = self._cached_layout or self._layout_value()
- is_multi = isinstance(output, (list, tuple))
-
- if layout is None and not self.config.suppress_callback_exceptions:
- # Without a layout, we can't do validation on the IDs and
- # properties of the elements in the callback.
- raise exceptions.LayoutIsNotDefined(
- dedent(
- """
- Attempting to assign a callback to the application but
- the `layout` property has not been assigned.
- Assign the `layout` property before assigning callbacks.
- Alternatively, suppress this warning by setting
- `suppress_callback_exceptions=True`
- """
- )
- )
-
- outputs = output if is_multi else [output]
- for args, obj, name in [
- (outputs, Output, "Output"),
- (inputs, Input, "Input"),
- (state, State, "State"),
- ]:
-
- if not isinstance(args, (list, tuple)):
- raise exceptions.IncorrectTypeException(
- "The {} argument `{}` must be "
- "a list or tuple of `dash.dependencies.{}`s.".format(
- name.lower(), str(args), name
- )
- )
-
- for arg in args:
- if not isinstance(arg, obj):
- raise exceptions.IncorrectTypeException(
- "The {} argument `{}` must be "
- "of type `dash.{}`.".format(name.lower(), str(arg), name)
- )
-
- invalid_characters = ["."]
- if any(x in arg.component_id for x in invalid_characters):
- raise exceptions.InvalidComponentIdError(
- "The element `{}` contains {} in its ID. "
- "Periods are not allowed in IDs.".format(
- arg.component_id, invalid_characters
- )
- )
-
- if not self.config.suppress_callback_exceptions:
- layout_id = getattr(layout, "id", None)
- arg_id = arg.component_id
- arg_prop = getattr(arg, "component_property", None)
- if arg_id not in layout and arg_id != layout_id:
- all_ids = [k for k in layout]
- if layout_id:
- all_ids.append(layout_id)
- raise exceptions.NonExistentIdException(
- dedent(
- """
- Attempting to assign a callback to the
- component with the id "{0}" but no
- components with id "{0}" exist in the
- app\'s layout.\n\n
- Here is a list of IDs in layout:\n{1}\n\n
- If you are assigning callbacks to components
- that are generated by other callbacks
- (and therefore not in the initial layout), then
- you can suppress this exception by setting
- `suppress_callback_exceptions=True`.
- """
- ).format(arg_id, all_ids)
- )
-
- component = layout if layout_id == arg_id else layout[arg_id]
-
- if (
- arg_prop
- and arg_prop not in component.available_properties
- and not any(
- arg_prop.startswith(w)
- for w in component.available_wildcard_properties
- )
- ):
- raise exceptions.NonExistentPropException(
- dedent(
- """
- Attempting to assign a callback with
- the property "{0}" but the component
- "{1}" doesn't have "{0}" as a property.\n
- Here are the available properties in "{1}":
- {2}
- """
- ).format(
- arg_prop, arg_id, component.available_properties,
- )
- )
-
- if hasattr(arg, "component_event"):
- raise exceptions.NonExistentEventException(
- dedent(
- """
- Events have been removed.
- Use the associated property instead.
- """
- )
- )
-
- if state and not inputs:
- raise exceptions.MissingInputsException(
- dedent(
- """
- This callback has {} `State` {}
- but no `Input` elements.\n
- Without `Input` elements, this callback
- will never get called.\n
- (Subscribing to input components will cause the
- callback to be called whenever their values change.)
- """
- ).format(len(state), "elements" if len(state) > 1 else "element")
- )
-
- for i in inputs:
- bad = None
- if is_multi:
- for o in output:
- if o == i:
- bad = o
- else:
- if output == i:
- bad = output
- if bad:
- raise exceptions.SameInputOutputException(
- "Same output and input: {}".format(bad)
- )
-
- if is_multi:
- if len(set(output)) != len(output):
- raise exceptions.DuplicateCallbackOutput(
- "Same output was used more than once in a "
- "multi output callback!\n Duplicates:\n {}".format(
- ",\n".join(
- k
- for k, v in ((str(x), output.count(x)) for x in output)
- if v > 1
- )
- )
- )
-
- callback_id = _create_callback_id(output)
-
- callbacks = set(
- itertools.chain(
- *(
- x[2:-2].split("...") if x.startswith("..") else [x]
- for x in self.callback_map
- )
- )
- )
- ns = {"duplicates": set()}
- if is_multi:
-
- def duplicate_check():
- ns["duplicates"] = callbacks.intersection(str(y) for y in output)
- return ns["duplicates"]
-
- else:
-
- def duplicate_check():
- return callback_id in callbacks
-
- if duplicate_check():
- if is_multi:
- msg = dedent(
- """
- Multi output {} contains an `Output` object
- that was already assigned.
- Duplicates:
- {}
- """
- ).format(callback_id, pprint.pformat(ns["duplicates"]))
- else:
- msg = dedent(
- """
- You have already assigned a callback to the output
- with ID "{}" and property "{}". An output can only have
- a single callback function. Try combining your inputs and
- callback functions together into one function.
- """
- ).format(output.component_id, output.component_property)
- raise exceptions.DuplicateCallbackOutput(msg)
-
- @staticmethod
- def _validate_callback_output(output_value, output):
- valid = [str, dict, int, float, type(None), Component]
-
- def _raise_invalid(bad_val, outer_val, path, index=None, toplevel=False):
- bad_type = type(bad_val).__name__
- outer_id = (
- "(id={:s})".format(outer_val.id)
- if getattr(outer_val, "id", False)
- else ""
- )
- outer_type = type(outer_val).__name__
- raise exceptions.InvalidCallbackReturnValue(
- dedent(
- """
- The callback for `{output:s}`
- returned a {object:s} having type `{type:s}`
- which is not JSON serializable.
-
- {location_header:s}{location:s}
- and has string representation
- `{bad_val}`
-
- In general, Dash properties can only be
- dash components, strings, dictionaries, numbers, None,
- or lists of those.
- """
- ).format(
- output=repr(output),
- object="tree with one value" if not toplevel else "value",
- type=bad_type,
- location_header=(
- "The value in question is located at"
- if not toplevel
- else "The value in question is either the only value "
- "returned,\nor is in the top level of the returned "
- "list,"
- ),
- location=(
- "\n"
- + (
- "[{:d}] {:s} {:s}".format(index, outer_type, outer_id)
- if index is not None
- else ("[*] " + outer_type + " " + outer_id)
- )
- + "\n"
- + path
- + "\n"
- )
- if not toplevel
- else "",
- bad_val=bad_val,
- )
- )
-
- def _value_is_valid(val):
- return (
- # pylint: disable=unused-variable
- any([isinstance(val, x) for x in valid])
- or type(val).__name__ == "unicode"
- )
-
- def _validate_value(val, index=None):
- # val is a Component
- if isinstance(val, Component):
- # pylint: disable=protected-access
- for p, j in val._traverse_with_paths():
- # check each component value in the tree
- if not _value_is_valid(j):
- _raise_invalid(bad_val=j, outer_val=val, path=p, index=index)
-
- # Children that are not of type Component or
- # list/tuple not returned by traverse
- child = getattr(j, "children", None)
- if not isinstance(child, (tuple, collections.MutableSequence)):
- if child and not _value_is_valid(child):
- _raise_invalid(
- bad_val=child,
- outer_val=val,
- path=p + "\n" + "[*] " + type(child).__name__,
- index=index,
- )
-
- # Also check the child of val, as it will not be returned
- child = getattr(val, "children", None)
- if not isinstance(child, (tuple, collections.MutableSequence)):
- if child and not _value_is_valid(child):
- _raise_invalid(
- bad_val=child,
- outer_val=val,
- path=type(child).__name__,
- index=index,
- )
-
- # val is not a Component, but is at the top level of tree
- else:
- if not _value_is_valid(val):
- _raise_invalid(
- bad_val=val,
- outer_val=type(val).__name__,
- path="",
- index=index,
- toplevel=True,
- )
+ return flask.jsonify(self._callback_list)
+
+ def _insert_callback(self, output, inputs, state):
+ _validate.validate_callback(output, inputs, state)
+ callback_id = create_callback_id(output)
+ callback_spec = {
+ "output": callback_id,
+ "inputs": [c.to_dict() for c in inputs],
+ "state": [c.to_dict() for c in state],
+ "clientside_function": None,
+ }
+ self.callback_map[callback_id] = {
+ "inputs": callback_spec["inputs"],
+ "state": callback_spec["state"],
+ }
+ self._callback_list.append(callback_spec)
- if isinstance(output_value, list):
- for i, val in enumerate(output_value):
- _validate_value(val, index=i)
- else:
- _validate_value(output_value)
+ return callback_id
- # pylint: disable=dangerous-default-value
- def clientside_callback(self, clientside_function, output, inputs=[], state=[]):
+ def clientside_callback(self, clientside_function, output, inputs, state=()):
"""Create a callback that updates the output by calling a clientside
(JavaScript) function instead of a Python function.
- Unlike `@app.calllback`, `clientside_callback` is not a decorator:
+ Unlike `@app.callback`, `clientside_callback` is not a decorator:
it takes either a
`dash.dependencies.ClientsideFunction(namespace, function_name)`
argument that describes which JavaScript function to call
@@ -1216,8 +858,7 @@ def clientside_callback(self, clientside_function, output, inputs=[], state=[]):
)
```
"""
- self._validate_callback(output, inputs, state)
- callback_id = _create_callback_id(output)
+ self._insert_callback(output, inputs, state)
# If JS source is explicitly given, create a namespace and function
# name, then inject the code.
@@ -1231,14 +872,10 @@ def clientside_callback(self, clientside_function, output, inputs=[], state=[]):
function_name = "{}".format(out0.component_property)
self._inline_scripts.append(
- """
- var clientside = window.dash_clientside = window.dash_clientside || {{}};
- var ns = clientside["{0}"] = clientside["{0}"] || {{}};
- ns["{1}"] = {2};
- """.format(
- namespace.replace('"', '\\"'),
- function_name.replace('"', '\\"'),
- clientside_function,
+ _inline_clientside_template.format(
+ namespace=namespace.replace('"', '\\"'),
+ function_name=function_name.replace('"', '\\"'),
+ clientside_function=clientside_function,
)
)
@@ -1247,111 +884,57 @@ def clientside_callback(self, clientside_function, output, inputs=[], state=[]):
namespace = clientside_function.namespace
function_name = clientside_function.function_name
- self.callback_map[callback_id] = {
- "inputs": [
- {"id": c.component_id, "property": c.component_property} for c in inputs
- ],
- "state": [
- {"id": c.component_id, "property": c.component_property} for c in state
- ],
- "clientside_function": {
- "namespace": namespace,
- "function_name": function_name,
- },
+ self._callback_list[-1]["clientside_function"] = {
+ "namespace": namespace,
+ "function_name": function_name,
}
- # TODO - Update nomenclature.
- # "Parents" and "Children" should refer to the DOM tree
- # and not the dependency tree.
- # The dependency tree should use the nomenclature
- # "observer" and "controller".
- # "observers" listen for changes from their "controllers". For example,
- # if a graph depends on a dropdown, the graph is the "observer" and the
- # dropdown is a "controller". In this case the graph's "dependency" is
- # the dropdown.
- # TODO - Check this map for recursive or other ill-defined non-tree
- # relationships
- # pylint: disable=dangerous-default-value
- def callback(self, output, inputs=[], state=[]):
- self._validate_callback(output, inputs, state)
-
- callback_id = _create_callback_id(output)
+ def callback(self, output, inputs, state=()):
+ callback_id = self._insert_callback(output, inputs, state)
multi = isinstance(output, (list, tuple))
- self.callback_map[callback_id] = {
- "inputs": [
- {"id": c.component_id, "property": c.component_property} for c in inputs
- ],
- "state": [
- {"id": c.component_id, "property": c.component_property} for c in state
- ],
- }
-
def wrap_func(func):
@wraps(func)
def add_context(*args, **kwargs):
+ output_spec = kwargs.pop("outputs_list")
+
# don't touch the comment on the next line - used by debugger
output_value = func(*args, **kwargs) # %% callback invoked %%
- if multi:
- if not isinstance(output_value, (list, tuple)):
- raise exceptions.InvalidCallbackReturnValue(
- "The callback {} is a multi-output.\n"
- "Expected the output type to be a list"
- " or tuple but got {}.".format(
- callback_id, repr(output_value)
- )
- )
- if not len(output_value) == len(output):
- raise exceptions.InvalidCallbackReturnValue(
- "Invalid number of output values for {}.\n"
- " Expected {} got {}".format(
- callback_id, len(output), len(output_value)
- )
- )
+ if isinstance(output_value, _NoUpdate):
+ raise PreventUpdate
- component_ids = collections.defaultdict(dict)
- has_update = False
- for i, o in enumerate(output):
- val = output_value[i]
- if not isinstance(val, _NoUpdate):
- has_update = True
- o_id, o_prop = o.component_id, o.component_property
- component_ids[o_id][o_prop] = val
+ # wrap single outputs so we can treat them all the same
+ # for validation and response creation
+ if not multi:
+ output_value, output_spec = [output_value], [output_spec]
- if not has_update:
- raise exceptions.PreventUpdate
+ _validate.validate_multi_return(output_spec, output_value, callback_id)
- response = {"response": component_ids, "multi": True}
- else:
- if isinstance(output_value, _NoUpdate):
- raise exceptions.PreventUpdate
+ component_ids = collections.defaultdict(dict)
+ has_update = False
+ for val, spec in zip(output_value, output_spec):
+ if isinstance(val, _NoUpdate):
+ continue
+ for vali, speci in (
+ zip(val, spec) if isinstance(spec, list) else [[val, spec]]
+ ):
+ if not isinstance(vali, _NoUpdate):
+ has_update = True
+ id_str = stringify_id(speci["id"])
+ component_ids[id_str][speci["property"]] = vali
- response = {
- "response": {"props": {output.component_property: output_value}}
- }
+ if not has_update:
+ raise PreventUpdate
+
+ response = {"response": component_ids, "multi": True}
try:
jsonResponse = json.dumps(
response, cls=plotly.utils.PlotlyJSONEncoder
)
except TypeError:
- self._validate_callback_output(output_value, output)
- raise exceptions.InvalidCallbackReturnValue(
- dedent(
- """
- The callback for property `{property:s}`
- of component `{id:s}` returned a value
- which is not JSON serializable.
-
- In general, Dash properties can only be
- dash components, strings, dictionaries, numbers, None,
- or lists of those.
- """
- ).format(
- property=output.component_property, id=output.component_id,
- )
- )
+ _validate.fail_callback_output(output_value, output)
return jsonResponse
@@ -1363,74 +946,27 @@ def add_context(*args, **kwargs):
def dispatch(self):
body = flask.request.get_json()
- inputs = body.get("inputs", [])
- state = body.get("state", [])
+ flask.g.inputs_list = inputs = body.get("inputs", [])
+ flask.g.states_list = state = body.get("state", [])
output = body["output"]
+ outputs_list = body.get("outputs") or split_callback_id(output)
+ flask.g.outputs_list = outputs_list
- args = []
-
- flask.g.input_values = input_values = {
- "{}.{}".format(x["id"], x["property"]): x.get("value") for x in inputs
- }
- flask.g.state_values = {
- "{}.{}".format(x["id"], x["property"]): x.get("value") for x in state
- }
- changed_props = body.get("changedPropIds")
- flask.g.triggered_inputs = (
- [{"prop_id": x, "value": input_values[x]} for x in changed_props]
- if changed_props
- else []
- )
+ flask.g.input_values = input_values = inputs_to_dict(inputs)
+ flask.g.state_values = inputs_to_dict(state)
+ changed_props = body.get("changedPropIds", [])
+ flask.g.triggered_inputs = [
+ {"prop_id": x, "value": input_values.get(x)} for x in changed_props
+ ]
response = flask.g.dash_response = flask.Response(mimetype="application/json")
- for component_registration in self.callback_map[output]["inputs"]:
- args.append(
- [
- c.get("value", None)
- for c in inputs
- if c["property"] == component_registration["property"]
- and c["id"] == component_registration["id"]
- ][0]
- )
+ args = inputs_to_vals(inputs) + inputs_to_vals(state)
- for component_registration in self.callback_map[output]["state"]:
- args.append(
- [
- c.get("value", None)
- for c in state
- if c["property"] == component_registration["property"]
- and c["id"] == component_registration["id"]
- ][0]
- )
-
- response.set_data(self.callback_map[output]["callback"](*args))
+ func = self.callback_map[output]["callback"]
+ response.set_data(func(*args, outputs_list=outputs_list))
return response
- def _validate_layout(self):
- if self.layout is None:
- raise exceptions.NoLayoutException(
- "The layout was `None` "
- "at the time that `run_server` was called. "
- "Make sure to set the `layout` attribute of your application "
- "before running the server."
- )
-
- to_validate = self._layout_value()
-
- layout_id = getattr(self.layout, "id", None)
-
- component_ids = {layout_id} if layout_id else set()
- # pylint: disable=protected-access
- for component in to_validate._traverse():
- component_id = getattr(component, "id", None)
- if component_id and component_id in component_ids:
- raise exceptions.DuplicateIdError(
- "Duplicate component id found"
- " in the initial layout: `{}`".format(component_id)
- )
- component_ids.add(component_id)
-
def _setup_server(self):
# Apply _force_eager_loading overrides from modules
eager_loading = self.config.eager_loading
@@ -1445,7 +981,7 @@ def _setup_server(self):
if self.config.include_assets_files:
self._walk_assets_directory()
- self._validate_layout()
+ _validate.validate_layout(self.layout, self._layout_value())
self._generate_scripts_html()
self._generate_css_dist_html()
@@ -1500,11 +1036,11 @@ def _invalid_resources_handler(err):
@staticmethod
def _serve_default_favicon():
return flask.Response(
- pkgutil.get_data("dash", "favicon.ico"), content_type="image/x-icon",
+ pkgutil.get_data("dash", "favicon.ico"), content_type="image/x-icon"
)
def get_asset_url(self, path):
- asset = _get_asset_path(
+ asset = get_asset_path(
self.config.requests_pathname_prefix,
path,
self.config.assets_url_path.lstrip("/"),
@@ -1549,7 +1085,7 @@ def display_content(path):
return chapters.page_2
```
"""
- asset = _get_relative_path(self.config.requests_pathname_prefix, path,)
+ asset = get_relative_path(self.config.requests_pathname_prefix, path)
return asset
@@ -1600,11 +1136,11 @@ def display_content(path):
`page-1/sub-page-1`
```
"""
- return _strip_relative_path(self.config.requests_pathname_prefix, path,)
+ return strip_relative_path(self.config.requests_pathname_prefix, path)
def _setup_dev_tools(self, **kwargs):
debug = kwargs.get("debug", False)
- dev_tools = self._dev_tools = _AttributeDict()
+ dev_tools = self._dev_tools = AttributeDict()
for attr in (
"ui",
@@ -1735,7 +1271,7 @@ def enable_dev_tools(
if dev_tools.hot_reload:
_reload = self._hot_reload
- _reload.hash = _generate_hash()
+ _reload.hash = generate_hash()
component_packages_dist = [
os.path.dirname(package.path)
@@ -1792,7 +1328,7 @@ def _on_assets_change(self, filename, modified, deleted):
_reload = self._hot_reload
with _reload.lock:
_reload.hard = True
- _reload.hash = _generate_hash()
+ _reload.hash = generate_hash()
if self.config.assets_folder in filename:
asset_path = (
diff --git a/dash/dependencies.py b/dash/dependencies.py
index 012778028d..fa79b842d5 100644
--- a/dash/dependencies.py
+++ b/dash/dependencies.py
@@ -1,17 +1,99 @@
-class DashDependency:
- # pylint: disable=too-few-public-methods
+import json
+
+
+class _Wildcard: # pylint: disable=too-few-public-methods
+ def __init__(self, name):
+ self._name = name
+
+ def __str__(self):
+ return self._name
+
+ def __repr__(self):
+ return "<{}>".format(self)
+
+ def to_json(self):
+ # used in serializing wildcards - arrays are not allowed as
+ # id values, so make the wildcards look like length-1 arrays.
+ return '["{}"]'.format(self._name)
+
+
+MATCH = _Wildcard("MATCH")
+ALL = _Wildcard("ALL")
+ALLSMALLER = _Wildcard("ALLSMALLER")
+
+
+class DashDependency: # pylint: disable=too-few-public-methods
def __init__(self, component_id, component_property):
self.component_id = component_id
self.component_property = component_property
def __str__(self):
- return "{}.{}".format(self.component_id, self.component_property)
+ return "{}.{}".format(self.component_id_str(), self.component_property)
def __repr__(self):
return "<{} `{}`>".format(self.__class__.__name__, self)
+ def component_id_str(self):
+ i = self.component_id
+
+ def _dump(v):
+ return json.dumps(v, sort_keys=True, separators=(",", ":"))
+
+ def _json(k, v):
+ vstr = v.to_json() if hasattr(v, "to_json") else json.dumps(v)
+ return "{}:{}".format(json.dumps(k), vstr)
+
+ if isinstance(i, dict):
+ return "{" + ",".join(_json(k, i[k]) for k in sorted(i)) + "}"
+
+ return i
+
+ def to_dict(self):
+ return {"id": self.component_id_str(), "property": self.component_property}
+
def __eq__(self, other):
- return isinstance(other, DashDependency) and str(self) == str(other)
+ """
+ We use "==" to denote two deps that refer to the same prop on
+ the same component. In the case of wildcard deps, this means
+ the same prop on *at least one* of the same components.
+ """
+ return (
+ isinstance(other, DashDependency)
+ and self.component_property == other.component_property
+ and self._id_matches(other)
+ )
+
+ def _id_matches(self, other):
+ my_id = self.component_id
+ other_id = other.component_id
+ self_dict = isinstance(my_id, dict)
+ other_dict = isinstance(other_id, dict)
+
+ if self_dict != other_dict:
+ return False
+ if self_dict:
+ if set(my_id.keys()) != set(other_id.keys()):
+ return False
+
+ for k, v in my_id.items():
+ other_v = other_id[k]
+ if v == other_v:
+ continue
+ v_wild = isinstance(v, _Wildcard)
+ other_wild = isinstance(other_v, _Wildcard)
+ if v_wild or other_wild:
+ if not (v_wild and other_wild):
+ continue # one wild, one not
+ if v is ALL or other_v is ALL:
+ continue # either ALL
+ if v is MATCH or other_v is MATCH:
+ return False # one MATCH, one ALLSMALLER
+ else:
+ return False
+ return True
+
+ # both strings
+ return my_id == other_id
def __hash__(self):
return hash(str(self))
@@ -20,17 +102,22 @@ def __hash__(self):
class Output(DashDependency): # pylint: disable=too-few-public-methods
"""Output of a callback."""
+ allowed_wildcards = (MATCH, ALL)
+
class Input(DashDependency): # pylint: disable=too-few-public-methods
- """Input of callback trigger an update when it is updated."""
+ """Input of callback: trigger an update when it is updated."""
+
+ allowed_wildcards = (MATCH, ALL, ALLSMALLER)
class State(DashDependency): # pylint: disable=too-few-public-methods
- """Use the value of a state in a callback but don't trigger updates."""
+ """Use the value of a State in a callback but don't trigger updates."""
+
+ allowed_wildcards = (MATCH, ALL, ALLSMALLER)
-class ClientsideFunction:
- # pylint: disable=too-few-public-methods
+class ClientsideFunction: # pylint: disable=too-few-public-methods
def __init__(self, namespace=None, function_name=None):
if namespace.startswith("_dashprivate_"):
diff --git a/dash/development/base_component.py b/dash/development/base_component.py
index 9a89e9da74..b68b359941 100644
--- a/dash/development/base_component.py
+++ b/dash/development/base_component.py
@@ -3,7 +3,7 @@
import sys
from future.utils import with_metaclass
-from .._utils import patch_collections_abc
+from .._utils import patch_collections_abc, _strings, stringify_id
MutableSequence = patch_collections_abc("MutableSequence")
@@ -121,6 +121,24 @@ def __init__(self, **kwargs):
+ "Prop {} has value {}\n".format(k, repr(v))
)
+ if k == "id":
+ if isinstance(v, dict):
+ for id_key, id_val in v.items():
+ if not isinstance(id_key, _strings):
+ raise TypeError(
+ "dict id keys must be strings,\n"
+ + "found {!r} in id {!r}".format(id_key, v)
+ )
+ if not isinstance(id_val, _strings + (int, float, bool)):
+ raise TypeError(
+ "dict id values must be strings, numbers or bools,\n"
+ + "found {!r} in id {!r}".format(id_val, v)
+ )
+ elif not isinstance(v, _strings):
+ raise TypeError(
+ "`id` prop must be a string or dict, not {!r}".format(v)
+ )
+
setattr(self, k, v)
def to_plotly_json(self):
@@ -244,14 +262,16 @@ def _traverse(self):
for t in self._traverse_with_paths():
yield t[1]
+ @staticmethod
+ def _id_str(component):
+ id_ = stringify_id(getattr(component, "id", ""))
+ return id_ and " (id={:s})".format(id_)
+
def _traverse_with_paths(self):
"""Yield each item with its path in the tree."""
children = getattr(self, "children", None)
children_type = type(children).__name__
- children_id = (
- "(id={:s})".format(children.id) if getattr(children, "id", False) else ""
- )
- children_string = children_type + " " + children_id
+ children_string = children_type + self._id_str(children)
# children is just a component
if isinstance(children, Component):
@@ -263,10 +283,8 @@ def _traverse_with_paths(self):
# children is a list of components
elif isinstance(children, (tuple, MutableSequence)):
for idx, i in enumerate(children):
- list_path = "[{:d}] {:s} {}".format(
- idx,
- type(i).__name__,
- "(id={:s})".format(i.id) if getattr(i, "id", False) else "",
+ list_path = "[{:d}] {:s}{}".format(
+ idx, type(i).__name__, self._id_str(i)
)
yield list_path, i
@@ -279,7 +297,6 @@ def __iter__(self):
"""Yield IDs in the tree of children."""
for t in self._traverse():
if isinstance(t, Component) and getattr(t, "id", None) is not None:
-
yield t.id
def __len__(self):
diff --git a/dash/development/build_process.py b/dash/development/build_process.py
index 283c3ad460..f621d1e6cf 100644
--- a/dash/development/build_process.py
+++ b/dash/development/build_process.py
@@ -13,7 +13,7 @@
logger = logging.getLogger(__name__)
coloredlogs.install(
- fmt="%(asctime)s,%(msecs)03d %(levelname)s - %(message)s", datefmt="%H:%M:%S",
+ fmt="%(asctime)s,%(msecs)03d %(levelname)s - %(message)s", datefmt="%H:%M:%S"
)
@@ -150,7 +150,7 @@ def __init__(self):
"""dash-renderer's path is binding with the dash folder hierarchy."""
super(Renderer, self).__init__(
self._concat(
- os.path.dirname(__file__), os.pardir, os.pardir, "dash-renderer",
+ os.path.dirname(__file__), os.pardir, os.pardir, "dash-renderer"
),
(
("@babel", "polyfill", "dist", "polyfill.min.js", None),
diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py
index 9702b72d3d..fd1c1d62ec 100644
--- a/dash/development/component_generator.py
+++ b/dash/development/component_generator.py
@@ -31,7 +31,7 @@
class _CombinedFormatter(
- argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter,
+ argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
):
pass
@@ -144,7 +144,7 @@ def cli():
)
parser.add_argument("components_source", help="React components source directory.")
parser.add_argument(
- "project_shortname", help="Name of the project to export the classes files.",
+ "project_shortname", help="Name of the project to export the classes files."
)
parser.add_argument(
"-p",
diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py
index 648c171cd5..d7b06e7f3b 100644
--- a/dash/development/component_loader.py
+++ b/dash/development/component_loader.py
@@ -52,7 +52,7 @@ def load_components(metadata_path, namespace="default_namespace"):
# the name of the component atm.
name = componentPath.split("/").pop().split(".")[0]
component = generate_class(
- name, componentData["props"], componentData["description"], namespace,
+ name, componentData["props"], componentData["description"], namespace
)
components.append(component)
diff --git a/dash/exceptions.py b/dash/exceptions.py
index 4756da912d..54439735fc 100644
--- a/dash/exceptions.py
+++ b/dash/exceptions.py
@@ -1,5 +1,9 @@
+from textwrap import dedent
+
+
class DashException(Exception):
- pass
+ def __init__(self, msg=""):
+ super(DashException, self).__init__(dedent(msg).strip())
class ObsoleteKwargException(DashException):
@@ -14,34 +18,14 @@ class CallbackException(DashException):
pass
-class NonExistentIdException(CallbackException):
- pass
-
-
-class NonExistentPropException(CallbackException):
- pass
-
-
class NonExistentEventException(CallbackException):
pass
-class UndefinedLayoutException(CallbackException):
- pass
-
-
class IncorrectTypeException(CallbackException):
pass
-class MissingInputsException(CallbackException):
- pass
-
-
-class LayoutIsNotDefined(CallbackException):
- pass
-
-
class IDsCantContainPeriods(CallbackException):
pass
@@ -51,15 +35,6 @@ class InvalidComponentIdError(IDsCantContainPeriods):
pass
-class CantHaveMultipleOutputs(CallbackException):
- pass
-
-
-# Renamed for less confusion with multi output.
-class DuplicateCallbackOutput(CantHaveMultipleOutputs):
- pass
-
-
class PreventUpdate(CallbackException):
pass
@@ -92,10 +67,6 @@ class ResourceException(DashException):
pass
-class SameInputOutputException(CallbackException):
- pass
-
-
class MissingCallbackContextException(CallbackException):
pass
diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py
index 0b52a9a0e7..a14f421abd 100644
--- a/dash/testing/application_runners.py
+++ b/dash/testing/application_runners.py
@@ -15,11 +15,7 @@
import flask
import requests
-from dash.testing.errors import (
- NoAppFoundError,
- TestingTimeoutError,
- ServerCloseError,
-)
+from dash.testing.errors import NoAppFoundError, TestingTimeoutError, ServerCloseError
import dash.testing.wait as wait
@@ -262,7 +258,7 @@ def start(self, app, start_timeout=2, cwd=None):
# app is a string chunk, we make a temporary folder to store app.R
# and its relevants assets
self._tmp_app_path = os.path.join(
- "/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex,
+ "/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex
)
try:
os.mkdir(self.tmp_app_path)
diff --git a/dash/testing/browser.py b/dash/testing/browser.py
index 6a98aae8e4..554e3ad5ec 100644
--- a/dash/testing/browser.py
+++ b/dash/testing/browser.py
@@ -20,18 +20,9 @@
MoveTargetOutOfBoundsException,
)
-from dash.testing.wait import (
- text_to_equal,
- style_to_equal,
- contains_text,
- until,
-)
+from dash.testing.wait import text_to_equal, style_to_equal, contains_text, until
from dash.testing.dash_page import DashPageMixin
-from dash.testing.errors import (
- DashAppLoadingError,
- BrowserError,
- TestingTimeoutError,
-)
+from dash.testing.errors import DashAppLoadingError, BrowserError, TestingTimeoutError
from dash.testing.consts import SELENIUM_GRID_DEFAULT
@@ -39,6 +30,7 @@
class Browser(DashPageMixin):
+ # pylint: disable=too-many-arguments
def __init__(
self,
browser,
@@ -51,6 +43,7 @@ def __init__(
percy_finalize=True,
percy_assets_root="",
wait_timeout=10,
+ pause=False,
):
self._browser = browser.lower()
self._remote_url = remote_url
@@ -63,6 +56,7 @@ def __init__(
self._wait_timeout = wait_timeout
self._percy_finalize = percy_finalize
self._percy_run = percy_run
+ self._pause = pause
self._driver = until(self.get_webdriver, timeout=1)
self._driver.implicitly_wait(2)
@@ -151,9 +145,8 @@ def percy_snapshot(self, name="", wait_for_callbacks=False):
# as diff reference for the build run.
logger.error(
"wait_for_callbacks failed => status of invalid rqs %s",
- list(_ for _ in self.redux_state_rqs if not _.get("responseTime")),
+ self.redux_state_rqs,
)
- logger.debug("full content of the rqs => %s", self.redux_state_rqs)
self.percy_runner.snapshot(name=snapshot_name)
@@ -227,6 +220,19 @@ def wait_for_element_by_css_selector(self, selector, timeout=None):
),
)
+ def wait_for_no_elements(self, selector, timeout=None):
+ """Explicit wait until an element is NOT found. timeout defaults to
+ the fixture's `wait_timeout`."""
+ until(
+ # if we use get_elements it waits a long time to see if they appear
+ # so this one calls out directly to execute_script
+ lambda: self.driver.execute_script(
+ "return document.querySelectorAll('{}').length".format(selector)
+ )
+ == 0,
+ timeout if timeout else self._wait_timeout,
+ )
+
def wait_for_element_by_id(self, element_id, timeout=None):
"""Explicit wait until the element is present, timeout if not set,
equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with
@@ -309,6 +315,14 @@ def wait_for_page(self, url=None, timeout=10):
)
)
+ if self._pause:
+ try:
+ import pdb as pdb_
+ except ImportError:
+ import ipdb as pdb_
+
+ pdb_.set_trace()
+
def select_dcc_dropdown(self, elem_or_selector, value=None, index=None):
dropdown = self._get_element(elem_or_selector)
dropdown.click()
@@ -328,7 +342,7 @@ def select_dcc_dropdown(self, elem_or_selector, value=None, index=None):
return
logger.error(
- "cannot find matching option using value=%s or index=%s", value, index,
+ "cannot find matching option using value=%s or index=%s", value, index
)
def toggle_window(self):
@@ -471,7 +485,7 @@ def clear_input(self, elem_or_selector):
).perform()
def zoom_in_graph_by_ratio(
- self, elem_or_selector, start_fraction=0.5, zoom_box_fraction=0.2, compare=True,
+ self, elem_or_selector, start_fraction=0.5, zoom_box_fraction=0.2, compare=True
):
"""Zoom out a graph with a zoom box fraction of component dimension
default start at middle with a rectangle of 1/5 of the dimension use
diff --git a/dash/testing/dash_page.py b/dash/testing/dash_page.py
index 1ff32b5338..63b30d407a 100644
--- a/dash/testing/dash_page.py
+++ b/dash/testing/dash_page.py
@@ -4,7 +4,7 @@
class DashPageMixin(object):
def _get_dash_dom_by_attribute(self, attr):
return BeautifulSoup(
- self.find_element(self.dash_entry_locator).get_attribute(attr), "lxml",
+ self.find_element(self.dash_entry_locator).get_attribute(attr), "lxml"
)
@property
@@ -25,30 +25,33 @@ def dash_innerhtml_dom(self):
@property
def redux_state_paths(self):
- return self.driver.execute_script("return window.store.getState().paths")
+ return self.driver.execute_script(
+ """
+ var p = window.store.getState().paths;
+ return {strs: p.strs, objs: p.objs}
+ """
+ )
@property
def redux_state_rqs(self):
- return self.driver.execute_script("return window.store.getState().requestQueue")
+ return self.driver.execute_script(
+ """
+ return window.store.getState().pendingCallbacks.map(function(cb) {
+ var out = {};
+ for (var key in cb) {
+ if (typeof cb[key] !== 'function') { out[key] = cb[key]; }
+ }
+ return out;
+ })
+ """
+ )
@property
def window_store(self):
return self.driver.execute_script("return window.store")
def _wait_for_callbacks(self):
- if self.window_store:
- # note that there is still a small chance of FP (False Positive)
- # where we get two earlier requests in the queue, this returns
- # True but there are still more requests to come
- return self.redux_state_rqs and all(
- (
- _.get("responseTime")
- for _ in self.redux_state_rqs
- if _.get("controllerId")
- )
- )
-
- return True
+ return not self.window_store or self.redux_state_rqs == []
def get_local_storage(self, store_id="local"):
return self.driver.execute_script(
diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py
index 5d107510c0..724f07c6fd 100644
--- a/dash/testing/plugin.py
+++ b/dash/testing/plugin.py
@@ -4,11 +4,7 @@
try:
- from dash.testing.application_runners import (
- ThreadedRunner,
- ProcessRunner,
- RRunner,
- )
+ from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner
from dash.testing.browser import Browser
from dash.testing.composite import DashComposite, DashRComposite
except ImportError:
@@ -26,7 +22,7 @@ def pytest_addoption(parser):
)
dash.addoption(
- "--remote", action="store_true", help="instruct pytest to use selenium grid",
+ "--remote", action="store_true", help="instruct pytest to use selenium grid"
)
dash.addoption(
@@ -37,7 +33,7 @@ def pytest_addoption(parser):
)
dash.addoption(
- "--headless", action="store_true", help="set this flag to run in headless mode",
+ "--headless", action="store_true", help="set this flag to run in headless mode"
)
dash.addoption(
@@ -53,6 +49,12 @@ def pytest_addoption(parser):
help="set this flag to control percy finalize at CI level",
)
+ dash.addoption(
+ "--pause",
+ action="store_true",
+ help="pause using pdb after opening the test app, so you can interact with it",
+ )
+
@pytest.mark.tryfirst
def pytest_addhooks(pluginmanager):
@@ -118,6 +120,7 @@ def dash_br(request, tmpdir):
download_path=tmpdir.mkdir("download").strpath,
percy_assets_root=request.config.getoption("percy_assets"),
percy_finalize=request.config.getoption("nopercyfinalize"),
+ pause=request.config.getoption("pause"),
) as browser:
yield browser
@@ -134,6 +137,7 @@ def dash_duo(request, dash_thread_server, tmpdir):
download_path=tmpdir.mkdir("download").strpath,
percy_assets_root=request.config.getoption("percy_assets"),
percy_finalize=request.config.getoption("nopercyfinalize"),
+ pause=request.config.getoption("pause"),
) as dc:
yield dc
@@ -150,5 +154,6 @@ def dashr(request, dashr_server, tmpdir):
download_path=tmpdir.mkdir("download").strpath,
percy_assets_root=request.config.getoption("percy_assets"),
percy_finalize=request.config.getoption("nopercyfinalize"),
+ pause=request.config.getoption("pause"),
) as dc:
yield dc
diff --git a/dash/testing/wait.py b/dash/testing/wait.py
index 29c37f46ad..316d5f3632 100644
--- a/dash/testing/wait.py
+++ b/dash/testing/wait.py
@@ -10,7 +10,7 @@
def until(
- wait_cond, timeout, poll=0.1, msg="expected condition not met within timeout",
+ wait_cond, timeout, poll=0.1, msg="expected condition not met within timeout"
): # noqa: C0330
res = wait_cond()
logger.debug(
@@ -31,7 +31,7 @@ def until(
def until_not(
- wait_cond, timeout, poll=0.1, msg="expected condition met within timeout",
+ wait_cond, timeout, poll=0.1, msg="expected condition met within timeout"
): # noqa: C0330
res = wait_cond()
logger.debug(
diff --git a/tests/integration/callbacks/state_path.json b/tests/integration/callbacks/state_path.json
index 7c6bf0ff87..94ca6b4dcd 100644
--- a/tests/integration/callbacks/state_path.json
+++ b/tests/integration/callbacks/state_path.json
@@ -1,146 +1,155 @@
{
"chapter1": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
- "chapter1-header": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 0
- ],
- "chapter1-controls": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 1
- ],
- "chapter1-label": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 2
- ],
- "chapter1-graph": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 3
- ]
+ "objs": {},
+ "strs": {
+ "toc": ["props", "children", 0],
+ "body": ["props", "children", 1],
+ "chapter1-header": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 0
+ ],
+ "chapter1-controls": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 1
+ ],
+ "chapter1-label": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 2
+ ],
+ "chapter1-graph": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 3
+ ]
+ }
},
"chapter2": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
- "chapter2-header": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 0
- ],
- "chapter2-controls": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 1
- ],
- "chapter2-label": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 2
- ],
- "chapter2-graph": [
- "props",
- "children",
- 1,
- "props",
- "children",
- "props",
- "children",
- 3
- ]
+ "objs": {},
+ "strs": {
+ "toc": ["props", "children", 0],
+ "body": ["props", "children", 1],
+ "chapter2-header": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 0
+ ],
+ "chapter2-controls": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 1
+ ],
+ "chapter2-label": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 2
+ ],
+ "chapter2-graph": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 3
+ ]
+ }
},
"chapter3": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
- "chapter3-header": [
- "props",
- "children",
- 1,
- "props",
- "children",
- 0,
- "props",
- "children",
- "props",
- "children",
- 0
- ],
- "chapter3-label": [
- "props",
- "children",
- 1,
- "props",
- "children",
- 0,
- "props",
- "children",
- "props",
- "children",
- 1
- ],
- "chapter3-graph": [
- "props",
- "children",
- 1,
- "props",
- "children",
- 0,
- "props",
- "children",
- "props",
- "children",
- 2
- ],
- "chapter3-controls": [
- "props",
- "children",
- 1,
- "props",
- "children",
- 0,
- "props",
- "children",
- "props",
- "children",
- 3
- ]
+ "objs": {},
+ "strs": {
+ "toc": ["props", "children", 0],
+ "body": ["props", "children", 1],
+ "chapter3-header": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ 0,
+ "props",
+ "children",
+ "props",
+ "children",
+ 0
+ ],
+ "chapter3-label": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ 0,
+ "props",
+ "children",
+ "props",
+ "children",
+ 1
+ ],
+ "chapter3-graph": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ 0,
+ "props",
+ "children",
+ "props",
+ "children",
+ 2
+ ],
+ "chapter3-controls": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ 0,
+ "props",
+ "children",
+ "props",
+ "children",
+ 3
+ ]
+ }
}
-}
\ No newline at end of file
+}
diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py
index 4b900f66ca..3d84d7a07f 100644
--- a/tests/integration/callbacks/test_basic_callback.py
+++ b/tests/integration/callbacks/test_basic_callback.py
@@ -1,10 +1,13 @@
+import json
from multiprocessing import Value
+import pytest
+
import dash_core_components as dcc
import dash_html_components as html
import dash_table
import dash
-from dash.dependencies import Input, Output
+from dash.dependencies import Input, Output, State
from dash.exceptions import PreventUpdate
@@ -38,8 +41,7 @@ def update_output(value):
assert call_count.value == 2 + len("hello world"), "initial count + each key stroke"
- rqs = dash_duo.redux_state_rqs
- assert len(rqs) == 1
+ assert dash_duo.redux_state_rqs == []
assert dash_duo.get_logs() == []
@@ -91,7 +93,9 @@ def update_input(value):
dash_duo.percy_snapshot(name="callback-generating-function-1")
- assert dash_duo.redux_state_paths == {
+ paths = dash_duo.redux_state_paths
+ assert paths["objs"] == {}
+ assert paths["strs"] == {
"input": ["props", "children", 0],
"output": ["props", "children", 1],
"sub-input-1": [
@@ -129,9 +133,7 @@ def update_input(value):
"#sub-output-1", pad_input.attrs["value"] + "deadbeef"
)
- rqs = dash_duo.redux_state_rqs
- assert rqs, "request queue is not empty"
- assert all((rq["status"] == 200 and not rq["rejected"] for rq in rqs))
+ assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty"
dash_duo.percy_snapshot(name="callback-generating-function-2")
assert dash_duo.get_logs() == [], "console is clean"
@@ -156,7 +158,7 @@ def test_cbsc003_callback_with_unloaded_async_component(dash_duo):
)
@app.callback(Output("output", "children"), [Input("btn", "n_clicks")])
- def update_graph(n_clicks):
+ def update_out(n_clicks):
if n_clicks is None:
raise PreventUpdate
@@ -164,6 +166,179 @@ def update_graph(n_clicks):
dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#output", "Hello")
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#output", "Bye")
+ assert dash_duo.get_logs() == []
+
+
+def test_cbsc004_callback_using_unloaded_async_component(dash_duo):
+ app = dash.Dash()
+ app.layout = html.Div(
+ [
+ dcc.Tabs(
+ [
+ dcc.Tab("boo!"),
+ dcc.Tab(
+ dash_table.DataTable(
+ id="table",
+ columns=[{"id": "a", "name": "A"}],
+ data=[{"a": "b"}],
+ )
+ ),
+ ]
+ ),
+ html.Button("Update Input", id="btn"),
+ html.Div("Hello", id="output"),
+ html.Div(id="output2"),
+ ]
+ )
+
+ @app.callback(
+ Output("output", "children"),
+ [Input("btn", "n_clicks")],
+ [State("table", "data")],
+ )
+ def update_out(n_clicks, data):
+ return json.dumps(data) + " - " + str(n_clicks)
+
+ @app.callback(
+ Output("output2", "children"),
+ [Input("btn", "n_clicks")],
+ [State("table", "derived_viewport_data")],
+ )
+ def update_out2(n_clicks, data):
+ return json.dumps(data) + " - " + str(n_clicks)
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#output", '[{"a": "b"}] - None')
+ dash_duo.wait_for_text_to_equal("#output2", "null - None")
+
dash_duo.find_element("#btn").click()
- assert dash_duo.find_element("#output").text == "Bye"
+ dash_duo.wait_for_text_to_equal("#output", '[{"a": "b"}] - 1')
+ dash_duo.wait_for_text_to_equal("#output2", "null - 1")
+
+ dash_duo.find_element(".tab:not(.tab--selected)").click()
+ dash_duo.wait_for_text_to_equal("#table th", "A")
+ # table props are in state so no change yet
+ dash_duo.wait_for_text_to_equal("#output2", "null - 1")
+
+ # repeat a few times, since one of the failure modes I saw during dev was
+ # intermittent - but predictably so?
+ for i in range(2, 10):
+ expected = '[{"a": "b"}] - ' + str(i)
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#output", expected)
+ # now derived props are available
+ dash_duo.wait_for_text_to_equal("#output2", expected)
+
assert dash_duo.get_logs() == []
+
+
+def test_cbsc005_children_types(dash_duo):
+ app = dash.Dash()
+ app.layout = html.Div([html.Button(id="btn"), html.Div("init", id="out")])
+
+ outputs = [
+ [None, ""],
+ ["a string", "a string"],
+ [123, "123"],
+ [123.45, "123.45"],
+ [[6, 7, 8], "678"],
+ [["a", "list", "of", "strings"], "alistofstrings"],
+ [["strings", 2, "numbers"], "strings2numbers"],
+ [["a string", html.Div("and a div")], "a string\nand a div"],
+ ]
+
+ @app.callback(Output("out", "children"), [Input("btn", "n_clicks")])
+ def set_children(n):
+ if n is None or n > len(outputs):
+ return dash.no_update
+ return outputs[n - 1][0]
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#out", "init")
+
+ for children, text in outputs:
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#out", text)
+
+
+def test_cbsc006_array_of_objects(dash_duo):
+ app = dash.Dash()
+ app.layout = html.Div(
+ [html.Button(id="btn"), dcc.Dropdown(id="dd"), html.Div(id="out")]
+ )
+
+ @app.callback(Output("dd", "options"), [Input("btn", "n_clicks")])
+ def set_options(n):
+ return [{"label": "opt{}".format(i), "value": i} for i in range(n or 0)]
+
+ @app.callback(Output("out", "children"), [Input("dd", "options")])
+ def set_out(opts):
+ print(repr(opts))
+ return len(opts)
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#out", "0")
+ for i in range(5):
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#out", str(i + 1))
+ dash_duo.select_dcc_dropdown("#dd", "opt{}".format(i))
+
+
+@pytest.mark.parametrize("refresh", [False, True])
+def test_cbsc007_parallel_updates(refresh, dash_duo):
+ # This is a funny case, that seems to mostly happen with dcc.Location
+ # but in principle could happen in other cases too:
+ # A callback chain (in this case the initial hydration) is set to update a
+ # value, but after that callback is queued and before it returns, that value
+ # is also set explicitly from the front end (in this case Location.pathname,
+ # which gets set in its componentDidMount during the render process, and
+ # callbacks are delayed until after rendering is finished because of the
+ # async table)
+ # At one point in the wildcard PR #1103, changing from requestQueue to
+ # pendingCallbacks, calling PreventUpdate in the callback would also skip
+ # any callbacks that depend on pathname, despite the new front-end-provided
+ # value.
+
+ app = dash.Dash()
+
+ app.layout = html.Div(
+ [
+ dcc.Location(id="loc", refresh=refresh),
+ html.Button("Update path", id="btn"),
+ dash_table.DataTable(id="t", columns=[{"name": "a", "id": "a"}]),
+ html.Div(id="out"),
+ ]
+ )
+
+ @app.callback(Output("t", "data"), [Input("loc", "pathname")])
+ def set_data(path):
+ return [{"a": (path or repr(path)) + ":a"}]
+
+ @app.callback(
+ Output("out", "children"), [Input("loc", "pathname"), Input("t", "data")]
+ )
+ def set_out(path, data):
+ return json.dumps(data) + " - " + (path or repr(path))
+
+ @app.callback(Output("loc", "pathname"), [Input("btn", "n_clicks")])
+ def set_path(n):
+ if not n:
+ raise PreventUpdate
+
+ return "/{0}".format(n)
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#out", '[{"a": "/:a"}] - /')
+ dash_duo.find_element("#btn").click()
+ # the refresh=True case here is testing that we really do get the right
+ # pathname, not the prevented default value from the layout.
+ dash_duo.wait_for_text_to_equal("#out", '[{"a": "/1:a"}] - /1')
+ if not refresh:
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2')
diff --git a/tests/integration/callbacks/test_callback_context.py b/tests/integration/callbacks/test_callback_context.py
new file mode 100644
index 0000000000..bddca9c6de
--- /dev/null
+++ b/tests/integration/callbacks/test_callback_context.py
@@ -0,0 +1,98 @@
+import json
+import pytest
+
+import dash_html_components as html
+import dash_core_components as dcc
+
+from dash import Dash, callback_context
+
+from dash.dependencies import Input, Output
+
+from dash.exceptions import PreventUpdate, MissingCallbackContextException
+
+
+def test_cbcx001_modified_response(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div([dcc.Input(id="input", value="ab"), html.Div(id="output")])
+
+ @app.callback(Output("output", "children"), [Input("input", "value")])
+ def update_output(value):
+ callback_context.response.set_cookie("dash cookie", value + " - cookie")
+ return value + " - output"
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#output", "ab - output")
+ input1 = dash_duo.find_element("#input")
+
+ input1.send_keys("cd")
+
+ dash_duo.wait_for_text_to_equal("#output", "abcd - output")
+ cookie = dash_duo.driver.get_cookie("dash cookie")
+ # cookie gets json encoded
+ assert cookie["value"] == '"abcd - cookie"'
+
+ assert not dash_duo.get_logs()
+
+
+def test_cbcx002_triggered(dash_duo):
+ app = Dash(__name__)
+
+ btns = ["btn-{}".format(x) for x in range(1, 6)]
+
+ app.layout = html.Div(
+ [html.Div([html.Button(btn, id=btn) for btn in btns]), html.Div(id="output")]
+ )
+
+ @app.callback(Output("output", "children"), [Input(x, "n_clicks") for x in btns])
+ def on_click(*args):
+ if not callback_context.triggered:
+ raise PreventUpdate
+ trigger = callback_context.triggered[0]
+ return "Just clicked {} for the {} time!".format(
+ trigger["prop_id"].split(".")[0], trigger["value"]
+ )
+
+ dash_duo.start_server(app)
+
+ for i in range(1, 5):
+ for btn in btns:
+ dash_duo.find_element("#" + btn).click()
+ dash_duo.wait_for_text_to_equal(
+ "#output", "Just clicked {} for the {} time!".format(btn, i)
+ )
+
+
+def test_cbcx003_no_callback_context():
+ for attr in ["inputs", "states", "triggered", "response"]:
+ with pytest.raises(MissingCallbackContextException):
+ getattr(callback_context, attr)
+
+
+def test_cbcx004_triggered_backward_compat(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div([html.Button("click!", id="btn"), html.Div(id="out")])
+
+ @app.callback(Output("out", "children"), [Input("btn", "n_clicks")])
+ def report_triggered(n):
+ triggered = callback_context.triggered
+ bool_val = "truthy" if triggered else "falsy"
+ split_propid = json.dumps(triggered[0]["prop_id"].split("."))
+ full_val = json.dumps(triggered)
+ return "triggered is {}, has prop/id {}, and full value {}".format(
+ bool_val, split_propid, full_val
+ )
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal(
+ "#out",
+ 'triggered is falsy, has prop/id ["", ""], and full value '
+ '[{"prop_id": ".", "value": null}]',
+ )
+
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal(
+ "#out",
+ 'triggered is truthy, has prop/id ["btn", "n_clicks"], and full value '
+ '[{"prop_id": "btn.n_clicks", "value": 1}]',
+ )
diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py
index 22b405f319..80656d5b5f 100644
--- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py
+++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py
@@ -110,12 +110,17 @@ def callback(value):
return {
"data": [
{
- "x": ["Call Counter"],
+ "x": ["Call Counter for: {}".format(counterId)],
"y": [call_counts[counterId].value],
"type": "bar",
}
],
- "layout": {"title": value},
+ "layout": {
+ "title": value,
+ "width": 500,
+ "height": 400,
+ "margin": {"autoexpand": False},
+ },
}
return callback
@@ -143,7 +148,7 @@ def update_label(value):
def check_chapter(chapter):
dash_duo.wait_for_element("#{}-graph:not(.dash-graph--pending)".format(chapter))
- for key in dash_duo.redux_state_paths:
+ for key in dash_duo.redux_state_paths["strs"]:
assert dash_duo.find_elements(
"#{}".format(key)
), "each element should exist in the dom"
@@ -160,20 +165,18 @@ def check_chapter(chapter):
wait.until(
lambda: (
dash_duo.driver.execute_script(
- "return document."
- 'querySelector("#{}-graph:not(.dash-graph--pending) .js-plotly-plot").'.format(
+ 'return document.querySelector("'
+ + "#{}-graph:not(.dash-graph--pending) .js-plotly-plot".format(
chapter
)
- + "layout.title.text"
+ + '").layout.title.text'
)
== value
),
TIMEOUT,
)
- rqs = dash_duo.redux_state_rqs
- assert rqs, "request queue is not empty"
- assert all((rq["status"] == 200 and not rq["rejected"] for rq in rqs))
+ assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty"
def check_call_counts(chapters, count):
for chapter in chapters:
@@ -215,12 +218,15 @@ def check_call_counts(chapters, count):
dash_duo.find_elements('input[type="radio"]')[3].click() # switch to 4
dash_duo.wait_for_text_to_equal("#body", "Just a string")
dash_duo.percy_snapshot(name="chapter-4")
- for key in dash_duo.redux_state_paths:
+
+ paths = dash_duo.redux_state_paths
+ assert paths["objs"] == {}
+ for key in paths["strs"]:
assert dash_duo.find_elements(
"#{}".format(key)
), "each element should exist in the dom"
- assert dash_duo.redux_state_paths == {
+ assert paths["strs"] == {
"toc": ["props", "children", 0],
"body": ["props", "children", 1],
}
@@ -228,7 +234,7 @@ def check_call_counts(chapters, count):
dash_duo.find_elements('input[type="radio"]')[0].click()
wait.until(
- lambda: dash_duo.redux_state_paths == EXPECTED_PATHS["chapter1"], TIMEOUT,
+ lambda: dash_duo.redux_state_paths == EXPECTED_PATHS["chapter1"], TIMEOUT
)
check_chapter("chapter1")
dash_duo.percy_snapshot(name="chapter-1-again")
diff --git a/tests/integration/callbacks/test_multiple_callbacks.py b/tests/integration/callbacks/test_multiple_callbacks.py
index 0f41923e69..009c8e7d10 100644
--- a/tests/integration/callbacks/test_multiple_callbacks.py
+++ b/tests/integration/callbacks/test_multiple_callbacks.py
@@ -1,9 +1,14 @@
import time
from multiprocessing import Value
+import pytest
+
import dash_html_components as html
+import dash_core_components as dcc
+import dash_table
import dash
-from dash.dependencies import Input, Output
+from dash.dependencies import Input, Output, State
+from dash.exceptions import PreventUpdate
def test_cbmt001_called_multiple_times_and_out_of_order(dash_duo):
@@ -27,9 +32,233 @@ def update_output(n_clicks):
assert call_count.value == 4, "get called 4 times"
assert dash_duo.find_element("#output").text == "3", "clicked button 3 times"
- rqs = dash_duo.redux_state_rqs
- assert len(rqs) == 1 and not rqs[0]["rejected"]
+ assert dash_duo.redux_state_rqs == []
dash_duo.percy_snapshot(
name="test_callbacks_called_multiple_times_and_out_of_order"
)
+
+
+def test_cbmt002_canceled_intermediate_callback(dash_duo):
+ # see https://github.com/plotly/dash/issues/1053
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Input(id="a", value="x"),
+ html.Div("b", id="b"),
+ html.Div("c", id="c"),
+ html.Div(id="out"),
+ ]
+ )
+
+ @app.callback(
+ Output("out", "children"),
+ [Input("a", "value"), Input("b", "children"), Input("c", "children")],
+ )
+ def set_out(a, b, c):
+ return "{}/{}/{}".format(a, b, c)
+
+ @app.callback(Output("b", "children"), [Input("a", "value")])
+ def set_b(a):
+ raise PreventUpdate
+
+ @app.callback(Output("c", "children"), [Input("a", "value")])
+ def set_c(a):
+ return a
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#out", "x/b/x")
+ chars = "x"
+ for i in list(range(10)) * 2:
+ dash_duo.find_element("#a").send_keys(str(i))
+ chars += str(i)
+ dash_duo.wait_for_text_to_equal("#out", "{0}/b/{0}".format(chars))
+
+
+def test_cbmt003_chain_with_table(dash_duo):
+ # see https://github.com/plotly/dash/issues/1071
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Div(id="a1"),
+ html.Div(id="a2"),
+ html.Div(id="b1"),
+ html.H1(id="b2"),
+ html.Button("Update", id="button"),
+ dash_table.DataTable(id="table"),
+ ]
+ )
+
+ @app.callback(
+ # Changing the order of outputs here fixes the issue
+ [Output("a2", "children"), Output("a1", "children")],
+ [Input("button", "n_clicks")],
+ )
+ def a12(n):
+ return "a2: {!s}".format(n), "a1: {!s}".format(n)
+
+ @app.callback(Output("b1", "children"), [Input("a1", "children")])
+ def b1(a1):
+ return "b1: '{!s}'".format(a1)
+
+ @app.callback(
+ Output("b2", "children"),
+ [Input("a2", "children"), Input("table", "selected_cells")],
+ )
+ def b2(a2, selected_cells):
+ return "b2: '{!s}', {!s}".format(a2, selected_cells)
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#a1", "a1: None")
+ dash_duo.wait_for_text_to_equal("#a2", "a2: None")
+ dash_duo.wait_for_text_to_equal("#b1", "b1: 'a1: None'")
+ dash_duo.wait_for_text_to_equal("#b2", "b2: 'a2: None', None")
+
+ dash_duo.find_element("#button").click()
+ dash_duo.wait_for_text_to_equal("#a1", "a1: 1")
+ dash_duo.wait_for_text_to_equal("#a2", "a2: 1")
+ dash_duo.wait_for_text_to_equal("#b1", "b1: 'a1: 1'")
+ dash_duo.wait_for_text_to_equal("#b2", "b2: 'a2: 1', None")
+
+ dash_duo.find_element("#button").click()
+ dash_duo.wait_for_text_to_equal("#a1", "a1: 2")
+ dash_duo.wait_for_text_to_equal("#a2", "a2: 2")
+ dash_duo.wait_for_text_to_equal("#b1", "b1: 'a1: 2'")
+ dash_duo.wait_for_text_to_equal("#b2", "b2: 'a2: 2', None")
+
+
+@pytest.mark.parametrize("MULTI", [False, True])
+def test_cbmt004_chain_with_sliders(MULTI, dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Button("Button", id="button"),
+ html.Div(
+ [
+ html.Label(id="label1"),
+ dcc.Slider(id="slider1", min=0, max=10, value=0),
+ ]
+ ),
+ html.Div(
+ [
+ html.Label(id="label2"),
+ dcc.Slider(id="slider2", min=0, max=10, value=0),
+ ]
+ ),
+ ]
+ )
+
+ if MULTI:
+
+ @app.callback(
+ [Output("slider1", "value"), Output("slider2", "value")],
+ [Input("button", "n_clicks")],
+ )
+ def update_slider_vals(n):
+ if not n:
+ raise PreventUpdate
+ return n, n
+
+ else:
+
+ @app.callback(Output("slider1", "value"), [Input("button", "n_clicks")])
+ def update_slider1_val(n):
+ if not n:
+ raise PreventUpdate
+ return n
+
+ @app.callback(Output("slider2", "value"), [Input("button", "n_clicks")])
+ def update_slider2_val(n):
+ if not n:
+ raise PreventUpdate
+ return n
+
+ @app.callback(Output("label1", "children"), [Input("slider1", "value")])
+ def update_slider1_label(val):
+ return "Slider1 value {}".format(val)
+
+ @app.callback(Output("label2", "children"), [Input("slider2", "value")])
+ def update_slider2_label(val):
+ return "Slider2 value {}".format(val)
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#label1", "")
+ dash_duo.wait_for_text_to_equal("#label2", "")
+
+ dash_duo.find_element("#button").click()
+ dash_duo.wait_for_text_to_equal("#label1", "Slider1 value 1")
+ dash_duo.wait_for_text_to_equal("#label2", "Slider2 value 1")
+
+ dash_duo.find_element("#button").click()
+ dash_duo.wait_for_text_to_equal("#label1", "Slider1 value 2")
+ dash_duo.wait_for_text_to_equal("#label2", "Slider2 value 2")
+
+
+def test_cbmt005_multi_converging_chain(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Button("Button 1", id="b1"),
+ html.Button("Button 2", id="b2"),
+ dcc.Slider(id="slider1", min=-5, max=5),
+ dcc.Slider(id="slider2", min=-5, max=5),
+ html.Div(id="out"),
+ ]
+ )
+
+ @app.callback(
+ [Output("slider1", "value"), Output("slider2", "value")],
+ [Input("b1", "n_clicks"), Input("b2", "n_clicks")],
+ )
+ def update_sliders(button1, button2):
+ if not dash.callback_context.triggered:
+ raise PreventUpdate
+
+ if dash.callback_context.triggered[0]["prop_id"] == "b1.n_clicks":
+ return -1, -1
+ else:
+ return 1, 1
+
+ @app.callback(
+ Output("out", "children"),
+ [Input("slider1", "value"), Input("slider2", "value")],
+ )
+ def update_graph(s1, s2):
+ return "x={}, y={}".format(s1, s2)
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#out", "")
+
+ dash_duo.find_element("#b1").click()
+ dash_duo.wait_for_text_to_equal("#out", "x=-1, y=-1")
+
+ dash_duo.find_element("#b2").click()
+ dash_duo.wait_for_text_to_equal("#out", "x=1, y=1")
+
+
+def test_cbmt006_derived_props(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [html.Div(id="output"), html.Button("click", id="btn"), dcc.Store(id="store")]
+ )
+
+ @app.callback(
+ Output("output", "children"),
+ [Input("store", "modified_timestamp")],
+ [State("store", "data")],
+ )
+ def on_data(ts, data):
+ return data
+
+ @app.callback(Output("store", "data"), [Input("btn", "n_clicks")])
+ def on_click(n_clicks):
+ return n_clicks or 0
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#output", "0")
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#output", "1")
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal("#output", "2")
diff --git a/tests/integration/callbacks/test_wildcards.py b/tests/integration/callbacks/test_wildcards.py
new file mode 100644
index 0000000000..2dbfeb368a
--- /dev/null
+++ b/tests/integration/callbacks/test_wildcards.py
@@ -0,0 +1,456 @@
+from multiprocessing import Value
+import pytest
+import re
+from selenium.webdriver.common.keys import Keys
+
+import dash_html_components as html
+import dash_core_components as dcc
+import dash
+from dash.dependencies import Input, Output, State, ALL, ALLSMALLER, MATCH
+
+
+def css_escape(s):
+ sel = re.sub("[\\{\\}\\\"\\'.:,]", lambda m: "\\" + m.group(0), s)
+ print(sel)
+ return sel
+
+
+def todo_app(content_callback):
+ app = dash.Dash(__name__)
+
+ content = html.Div(
+ [
+ html.Div("Dash To-Do list"),
+ dcc.Input(id="new-item"),
+ html.Button("Add", id="add"),
+ html.Button("Clear Done", id="clear-done"),
+ html.Div(id="list-container"),
+ html.Hr(),
+ html.Div(id="totals"),
+ ]
+ )
+
+ if content_callback:
+ app.layout = html.Div([html.Div(id="content"), dcc.Location(id="url")])
+
+ @app.callback(Output("content", "children"), [Input("url", "pathname")])
+ def display_content(_):
+ return content
+
+ else:
+ app.layout = content
+
+ style_todo = {"display": "inline", "margin": "10px"}
+ style_done = {"textDecoration": "line-through", "color": "#888"}
+ style_done.update(style_todo)
+
+ app.list_calls = Value("i", 0)
+ app.style_calls = Value("i", 0)
+ app.preceding_calls = Value("i", 0)
+ app.total_calls = Value("i", 0)
+
+ @app.callback(
+ [Output("list-container", "children"), Output("new-item", "value")],
+ [
+ Input("add", "n_clicks"),
+ Input("new-item", "n_submit"),
+ Input("clear-done", "n_clicks"),
+ ],
+ [
+ State("new-item", "value"),
+ State({"item": ALL}, "children"),
+ State({"item": ALL, "action": "done"}, "value"),
+ ],
+ )
+ def edit_list(add, add2, clear, new_item, items, items_done):
+ app.list_calls.value += 1
+ triggered = [t["prop_id"] for t in dash.callback_context.triggered]
+ adding = len(
+ [1 for i in triggered if i in ("add.n_clicks", "new-item.n_submit")]
+ )
+ clearing = len([1 for i in triggered if i == "clear-done.n_clicks"])
+ new_spec = [
+ (text, done)
+ for text, done in zip(items, items_done)
+ if not (clearing and done)
+ ]
+ if adding:
+ new_spec.append((new_item, []))
+ new_list = [
+ html.Div(
+ [
+ dcc.Checklist(
+ id={"item": i, "action": "done"},
+ options=[{"label": "", "value": "done"}],
+ value=done,
+ style={"display": "inline"},
+ ),
+ html.Div(
+ text, id={"item": i}, style=style_done if done else style_todo
+ ),
+ html.Div(id={"item": i, "preceding": True}, style=style_todo),
+ ],
+ style={"clear": "both"},
+ )
+ for i, (text, done) in enumerate(new_spec)
+ ]
+ return [new_list, "" if adding else new_item]
+
+ @app.callback(
+ Output({"item": MATCH}, "style"),
+ [Input({"item": MATCH, "action": "done"}, "value")],
+ )
+ def mark_done(done):
+ app.style_calls.value += 1
+ return style_done if done else style_todo
+
+ @app.callback(
+ Output({"item": MATCH, "preceding": True}, "children"),
+ [
+ Input({"item": ALLSMALLER, "action": "done"}, "value"),
+ Input({"item": MATCH, "action": "done"}, "value"),
+ ],
+ )
+ def show_preceding(done_before, this_done):
+ app.preceding_calls.value += 1
+ if this_done:
+ return ""
+ all_before = len(done_before)
+ done_before = len([1 for d in done_before if d])
+ out = "{} of {} preceding items are done".format(done_before, all_before)
+ if all_before == done_before:
+ out += " DO THIS NEXT!"
+ return out
+
+ @app.callback(
+ Output("totals", "children"), [Input({"item": ALL, "action": "done"}, "value")]
+ )
+ def show_totals(done):
+ app.total_calls.value += 1
+ count_all = len(done)
+ count_done = len([d for d in done if d])
+ result = "{} of {} items completed".format(count_done, count_all)
+ if count_all:
+ result += " - {}%".format(int(100 * count_done / count_all))
+ return result
+
+ return app
+
+
+@pytest.mark.parametrize("content_callback", (False, True))
+def test_cbwc001_todo_app(content_callback, dash_duo):
+ app = todo_app(content_callback)
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 0 items completed")
+ assert app.list_calls.value == 1
+ assert app.style_calls.value == 0
+ assert app.preceding_calls.value == 0
+ assert app.total_calls.value == 1
+
+ new_item = dash_duo.find_element("#new-item")
+ add_item = dash_duo.find_element("#add")
+ clear_done = dash_duo.find_element("#clear-done")
+
+ def assert_count(items):
+ assert len(dash_duo.find_elements("#list-container>div")) == items
+
+ def get_done_item(item):
+ selector = css_escape('#{"action":"done","item":%d} input' % item)
+ return dash_duo.find_element(selector)
+
+ def assert_item(item, text, done, prefix="", suffix=""):
+ dash_duo.wait_for_text_to_equal(css_escape('#{"item":%d}' % item), text)
+
+ expected_note = "" if done else (prefix + " preceding items are done" + suffix)
+ dash_duo.wait_for_text_to_equal(
+ css_escape('#{"item":%d,"preceding":true}' % item), expected_note
+ )
+
+ assert bool(get_done_item(item).get_attribute("checked")) == done
+
+ new_item.send_keys("apples")
+ add_item.click()
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 1 items completed - 0%")
+ assert_count(1)
+
+ new_item.send_keys("bananas")
+ add_item.click()
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 2 items completed - 0%")
+ assert_count(2)
+
+ new_item.send_keys("carrots")
+ add_item.click()
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 3 items completed - 0%")
+ assert_count(3)
+
+ new_item.send_keys("dates")
+ add_item.click()
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 4 items completed - 0%")
+ assert_count(4)
+ assert_item(0, "apples", False, "0 of 0", " DO THIS NEXT!")
+ assert_item(1, "bananas", False, "0 of 1")
+ assert_item(2, "carrots", False, "0 of 2")
+ assert_item(3, "dates", False, "0 of 3")
+
+ get_done_item(2).click()
+ dash_duo.wait_for_text_to_equal("#totals", "1 of 4 items completed - 25%")
+ assert_item(0, "apples", False, "0 of 0", " DO THIS NEXT!")
+ assert_item(1, "bananas", False, "0 of 1")
+ assert_item(2, "carrots", True)
+ assert_item(3, "dates", False, "1 of 3")
+
+ get_done_item(0).click()
+ dash_duo.wait_for_text_to_equal("#totals", "2 of 4 items completed - 50%")
+ assert_item(0, "apples", True)
+ assert_item(1, "bananas", False, "1 of 1", " DO THIS NEXT!")
+ assert_item(2, "carrots", True)
+ assert_item(3, "dates", False, "2 of 3")
+
+ clear_done.click()
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 2 items completed - 0%")
+ assert_count(2)
+ assert_item(0, "bananas", False, "0 of 0", " DO THIS NEXT!")
+ assert_item(1, "dates", False, "0 of 1")
+
+ get_done_item(0).click()
+ dash_duo.wait_for_text_to_equal("#totals", "1 of 2 items completed - 50%")
+ assert_item(0, "bananas", True)
+ assert_item(1, "dates", False, "1 of 1", " DO THIS NEXT!")
+
+ get_done_item(1).click()
+ dash_duo.wait_for_text_to_equal("#totals", "2 of 2 items completed - 100%")
+ assert_item(0, "bananas", True)
+ assert_item(1, "dates", True)
+
+ clear_done.click()
+ # This was a tricky one - trigger based on deleted components
+ dash_duo.wait_for_text_to_equal("#totals", "0 of 0 items completed")
+ assert_count(0)
+
+
+def fibonacci_app(clientside):
+ # This app tests 2 things in particular:
+ # - clientside callbacks work the same as server-side
+ # - callbacks using ALLSMALLER as an input to MATCH of the exact same id/prop
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Input(id="n", type="number", min=0, max=10, value=4),
+ html.Div(id="series"),
+ html.Div(id="sum"),
+ ]
+ )
+
+ @app.callback(Output("series", "children"), [Input("n", "value")])
+ def items(n):
+ return [html.Div(id={"i": i}) for i in range(n)]
+
+ if clientside:
+ app.clientside_callback(
+ """
+ function(vals) {
+ var len = vals.length;
+ return len < 2 ? len : +(vals[len - 1] || 0) + +(vals[len - 2] || 0);
+ }
+ """,
+ Output({"i": MATCH}, "children"),
+ [Input({"i": ALLSMALLER}, "children")],
+ )
+
+ app.clientside_callback(
+ """
+ function(vals) {
+ var sum = vals.reduce(function(a, b) { return +a + +b; }, 0);
+ return vals.length + ' elements, sum: ' + sum;
+ }
+ """,
+ Output("sum", "children"),
+ [Input({"i": ALL}, "children")],
+ )
+
+ else:
+
+ @app.callback(
+ Output({"i": MATCH}, "children"), [Input({"i": ALLSMALLER}, "children")]
+ )
+ def sequence(prev):
+ if len(prev) < 2:
+ return len(prev)
+ return int(prev[-1] or 0) + int(prev[-2] or 0)
+
+ @app.callback(Output("sum", "children"), [Input({"i": ALL}, "children")])
+ def show_sum(seq):
+ return "{} elements, sum: {}".format(
+ len(seq), sum(int(v or 0) for v in seq)
+ )
+
+ return app
+
+
+@pytest.mark.parametrize("clientside", (False, True))
+def test_cbwc002_fibonacci_app(clientside, dash_duo):
+ app = fibonacci_app(clientside)
+ dash_duo.start_server(app)
+
+ # app starts with 4 elements: 0, 1, 1, 2
+ dash_duo.wait_for_text_to_equal("#sum", "4 elements, sum: 4")
+
+ # add 5th item, "3"
+ dash_duo.find_element("#n").send_keys(Keys.UP)
+ dash_duo.wait_for_text_to_equal("#sum", "5 elements, sum: 7")
+
+ # add 6th item, "5"
+ dash_duo.find_element("#n").send_keys(Keys.UP)
+ dash_duo.wait_for_text_to_equal("#sum", "6 elements, sum: 12")
+
+ # add 7th item, "8"
+ dash_duo.find_element("#n").send_keys(Keys.UP)
+ dash_duo.wait_for_text_to_equal("#sum", "7 elements, sum: 20")
+
+ # back down all the way to no elements
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "6 elements, sum: 12")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "5 elements, sum: 7")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "4 elements, sum: 4")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "3 elements, sum: 2")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "2 elements, sum: 1")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "1 elements, sum: 0")
+ dash_duo.find_element("#n").send_keys(Keys.DOWN)
+ dash_duo.wait_for_text_to_equal("#sum", "0 elements, sum: 0")
+
+
+def test_cbwc003_same_keys(dash_duo):
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
+
+ app.layout = html.Div(
+ [
+ html.Button("Add Filter", id="add-filter", n_clicks=0),
+ html.Div(id="container", children=[]),
+ ]
+ )
+
+ @app.callback(
+ Output("container", "children"),
+ [Input("add-filter", "n_clicks")],
+ [State("container", "children")],
+ )
+ def display_dropdowns(n_clicks, children):
+ new_element = html.Div(
+ [
+ dcc.Dropdown(
+ id={"type": "dropdown", "index": n_clicks},
+ options=[
+ {"label": i, "value": i} for i in ["NYC", "MTL", "LA", "TOKYO"]
+ ],
+ ),
+ html.Div(id={"type": "output", "index": n_clicks}),
+ ]
+ )
+ return children + [new_element]
+
+ @app.callback(
+ Output({"type": "output", "index": MATCH}, "children"),
+ [Input({"type": "dropdown", "index": MATCH}, "value")],
+ [State({"type": "dropdown", "index": MATCH}, "id")],
+ )
+ def display_output(value, id):
+ return html.Div("Dropdown {} = {}".format(id["index"], value))
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#add-filter", "Add Filter")
+ dash_duo.select_dcc_dropdown(
+ '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"dropdown\\"\\}', "LA"
+ )
+ dash_duo.wait_for_text_to_equal(
+ '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 0 = LA"
+ )
+ dash_duo.find_element("#add-filter").click()
+ dash_duo.select_dcc_dropdown(
+ '#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"dropdown\\"\\}', "MTL"
+ )
+ dash_duo.wait_for_text_to_equal(
+ '#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 1 = MTL"
+ )
+ dash_duo.wait_for_text_to_equal(
+ '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 0 = LA"
+ )
+ dash_duo.wait_for_no_elements(dash_duo.devtools_error_count_locator)
+
+
+def test_cbwc004_layout_chunk_changed_props(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Input(id={"type": "input", "index": 1}, value="input-1"),
+ html.Div(id="container"),
+ html.Div(id="output-outer"),
+ html.Button("Show content", id="btn"),
+ ]
+ )
+
+ @app.callback(Output("container", "children"), [Input("btn", "n_clicks")])
+ def display_output(n):
+ if n:
+ return html.Div(
+ [
+ dcc.Input(id={"type": "input", "index": 2}, value="input-2"),
+ html.Div(id="output-inner"),
+ ]
+ )
+ else:
+ return "No content initially"
+
+ def trigger_info():
+ triggered = dash.callback_context.triggered
+ return "triggered is {} with prop_ids {}".format(
+ "Truthy" if triggered else "Falsy",
+ ", ".join(t["prop_id"] for t in triggered),
+ )
+
+ @app.callback(
+ Output("output-inner", "children"),
+ [Input({"type": "input", "index": ALL}, "value")],
+ )
+ def update_dynamic_output_pattern(wc_inputs):
+ return trigger_info()
+ # When this is triggered because output-2 was rendered,
+ # nothing has changed
+
+ @app.callback(
+ Output("output-outer", "children"),
+ [Input({"type": "input", "index": ALL}, "value")],
+ )
+ def update_output_on_page_pattern(value):
+ return trigger_info()
+ # When this triggered on page load,
+ # nothing has changed
+ # When dcc.Input(id={'type': 'input', 'index': 2})
+ # is rendered (from display_output)
+ # then `{'type': 'input', 'index': 2}` has changed
+
+ dash_duo.start_server(app)
+
+ dash_duo.wait_for_text_to_equal("#container", "No content initially")
+ dash_duo.wait_for_text_to_equal(
+ "#output-outer", "triggered is Falsy with prop_ids ."
+ )
+
+ dash_duo.find_element("#btn").click()
+ dash_duo.wait_for_text_to_equal(
+ "#output-outer",
+ 'triggered is Truthy with prop_ids {"index":2,"type":"input"}.value',
+ )
+ dash_duo.wait_for_text_to_equal(
+ "#output-inner", "triggered is Falsy with prop_ids ."
+ )
+
+ dash_duo.find_elements("input")[0].send_keys("X")
+ trigger_text = 'triggered is Truthy with prop_ids {"index":1,"type":"input"}.value'
+ dash_duo.wait_for_text_to_equal("#output-outer", trigger_text)
+ dash_duo.wait_for_text_to_equal("#output-inner", trigger_text)
diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py
new file mode 100644
index 0000000000..08190c6dad
--- /dev/null
+++ b/tests/integration/devtools/test_callback_validation.py
@@ -0,0 +1,697 @@
+import dash_core_components as dcc
+import dash_html_components as html
+from dash import Dash
+from dash.dependencies import Input, Output, State, MATCH, ALL, ALLSMALLER
+
+debugging = dict(
+ debug=True, use_reloader=False, use_debugger=True, dev_tools_hot_reload=False
+)
+
+
+def check_errors(dash_duo, specs):
+ # Order-agnostic check of all the errors shown.
+ # This is not fully general - despite the selectors below, it only applies
+ # to front-end errors with no back-end errors in the list.
+ cnt = len(specs)
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, str(cnt))
+
+ found = []
+ for i in range(cnt):
+ msg = dash_duo.find_elements(".dash-fe-error__title")[i].text
+ dash_duo.find_elements(".test-devtools-error-toggle")[i].click()
+ dash_duo.wait_for_element(".dash-backend-error,.dash-fe-error__info")
+ has_BE = dash_duo.driver.execute_script(
+ "return document.querySelectorAll('.dash-backend-error').length"
+ )
+ txt_selector = ".dash-backend-error" if has_BE else ".dash-fe-error__info"
+ txt = dash_duo.wait_for_element(txt_selector).text
+ dash_duo.find_elements(".test-devtools-error-toggle")[i].click()
+ dash_duo.wait_for_no_elements(".dash-backend-error")
+ found.append((msg, txt))
+
+ orig_found = found[:]
+
+ for i, (message, snippets) in enumerate(specs):
+ for j, (msg, txt) in enumerate(found):
+ if msg == message and all(snip in txt for snip in snippets):
+ print(j)
+ found.pop(j)
+ break
+ else:
+ raise AssertionError(
+ (
+ "error {} ({}) not found with text:\n"
+ " {}\nThe found messages were:\n---\n{}"
+ ).format(
+ i,
+ message,
+ "\n ".join(snippets),
+ "\n---\n".join(
+ "{}\n{}".format(msg, txt) for msg, txt in orig_found
+ ),
+ )
+ )
+
+ # ensure the errors didn't leave items in the pendingCallbacks queue
+ assert dash_duo.driver.execute_script("return document.title") == "Dash"
+
+
+def test_dvcv001_blank(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div()
+
+ @app.callback([], [])
+ def x():
+ return 42
+
+ dash_duo.start_server(app, **debugging)
+ check_errors(
+ dash_duo,
+ [
+ ["A callback is missing Inputs", ["there are no `Input` elements."]],
+ [
+ "A callback is missing Outputs",
+ ["Please provide an output for this callback:"],
+ ],
+ ],
+ )
+
+
+def test_dvcv002_blank_id_prop(dash_duo):
+ # TODO: remove suppress_callback_exceptions after we move that part to FE
+ app = Dash(__name__, suppress_callback_exceptions=True)
+ app.layout = html.Div([html.Div(id="a")])
+
+ @app.callback([Output("a", "children"), Output("", "")], [Input("", "")])
+ def x(a):
+ return a
+
+ dash_duo.start_server(app, **debugging)
+
+ # the first one is just an artifact... the other 4 we care about
+ specs = [
+ ["Same `Input` and `Output`", []],
+ [
+ "Callback item missing ID",
+ ['Input[0].id = ""', "Every item linked to a callback needs an ID"],
+ ],
+ [
+ "Callback property error",
+ [
+ 'Input[0].property = ""',
+ "expected `property` to be a non-empty string.",
+ ],
+ ],
+ [
+ "Callback item missing ID",
+ ['Output[1].id = ""', "Every item linked to a callback needs an ID"],
+ ],
+ [
+ "Callback property error",
+ [
+ 'Output[1].property = ""',
+ "expected `property` to be a non-empty string.",
+ ],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv003_duplicate_outputs_same_callback(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div([html.Div(id="a"), html.Div(id="b")])
+
+ @app.callback(
+ [Output("a", "children"), Output("a", "children")], [Input("b", "children")]
+ )
+ def x(b):
+ return b, b
+
+ @app.callback(
+ [Output({"a": 1}, "children"), Output({"a": ALL}, "children")],
+ [Input("b", "children")],
+ )
+ def y(b):
+ return b, b
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Overlapping wildcard callback outputs",
+ [
+ 'Output 1 ({"a":ALL}.children)',
+ 'overlaps another output ({"a":1}.children)',
+ "used in this callback",
+ ],
+ ],
+ [
+ "Duplicate callback Outputs",
+ ["Output 1 (a.children) is already used by this callback."],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv004_duplicate_outputs_across_callbacks(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div([html.Div(id="a"), html.Div(id="b"), html.Div(id="c")])
+
+ @app.callback(
+ [Output("a", "children"), Output("a", "style")], [Input("b", "children")]
+ )
+ def x(b):
+ return b, b
+
+ @app.callback(Output("b", "children"), [Input("b", "style")])
+ def y(b):
+ return b
+
+ @app.callback(Output("a", "children"), [Input("b", "children")])
+ def x2(b):
+ return b
+
+ @app.callback(
+ [Output("b", "children"), Output("b", "style")], [Input("c", "children")]
+ )
+ def y2(c):
+ return c
+
+ @app.callback(
+ [Output({"a": 1}, "children"), Output({"b": ALL, "c": 1}, "children")],
+ [Input("b", "children")],
+ )
+ def z(b):
+ return b, b
+
+ @app.callback(
+ [Output({"a": ALL}, "children"), Output({"b": 1, "c": ALL}, "children")],
+ [Input("b", "children")],
+ )
+ def z2(b):
+ return b, b
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Overlapping wildcard callback outputs",
+ [
+ # depending on the order callbacks get reported to the
+ # front end, either of these could have been registered first.
+ # so we use this oder-independent form that just checks for
+ # both prop_id's and the string "overlaps another output"
+ '({"b":1,"c":ALL}.children)',
+ "overlaps another output",
+ '({"b":ALL,"c":1}.children)',
+ "used in a different callback.",
+ ],
+ ],
+ [
+ "Overlapping wildcard callback outputs",
+ [
+ '({"a":ALL}.children)',
+ "overlaps another output",
+ '({"a":1}.children)',
+ "used in a different callback.",
+ ],
+ ],
+ ["Duplicate callback outputs", ["Output 0 (b.children) is already in use."]],
+ ["Duplicate callback outputs", ["Output 0 (a.children) is already in use."]],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv005_input_output_overlap(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div([html.Div(id="a"), html.Div(id="b"), html.Div(id="c")])
+
+ @app.callback(Output("a", "children"), [Input("a", "children")])
+ def x(a):
+ return a
+
+ @app.callback(
+ [Output("b", "children"), Output("c", "children")], [Input("c", "children")]
+ )
+ def y(c):
+ return c, c
+
+ @app.callback(Output({"a": ALL}, "children"), [Input({"a": 1}, "children")])
+ def x2(a):
+ return [a]
+
+ @app.callback(
+ [Output({"b": MATCH}, "children"), Output({"b": MATCH, "c": 1}, "children")],
+ [Input({"b": MATCH, "c": 1}, "children")],
+ )
+ def y2(c):
+ return c, c
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Same `Input` and `Output`",
+ [
+ 'Input 0 ({"b":MATCH,"c":1}.children)',
+ "can match the same component(s) as",
+ 'Output 1 ({"b":MATCH,"c":1}.children)',
+ ],
+ ],
+ [
+ "Same `Input` and `Output`",
+ [
+ 'Input 0 ({"a":1}.children)',
+ "can match the same component(s) as",
+ 'Output 0 ({"a":ALL}.children)',
+ ],
+ ],
+ [
+ "Same `Input` and `Output`",
+ ["Input 0 (c.children)", "matches Output 1 (c.children)"],
+ ],
+ [
+ "Same `Input` and `Output`",
+ ["Input 0 (a.children)", "matches Output 0 (a.children)"],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv006_inconsistent_wildcards(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div()
+
+ @app.callback(
+ [Output({"b": MATCH}, "children"), Output({"b": ALL, "c": 1}, "children")],
+ [Input({"b": MATCH, "c": 2}, "children")],
+ )
+ def x(c):
+ return c, [c]
+
+ @app.callback(
+ [Output({"a": MATCH}, "children")],
+ [Input({"b": MATCH}, "children"), Input({"c": ALLSMALLER}, "children")],
+ [State({"d": MATCH, "dd": MATCH}, "children"), State({"e": ALL}, "children")],
+ )
+ def y(b, c, d, e):
+ return b + c + d + e
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "`Input` / `State` wildcards not in `Output`s",
+ [
+ 'State 0 ({"d":MATCH,"dd":MATCH}.children)',
+ "has MATCH or ALLSMALLER on key(s) d, dd",
+ 'where Output 0 ({"a":MATCH}.children)',
+ ],
+ ],
+ [
+ "`Input` / `State` wildcards not in `Output`s",
+ [
+ 'Input 1 ({"c":ALLSMALLER}.children)',
+ "has MATCH or ALLSMALLER on key(s) c",
+ 'where Output 0 ({"a":MATCH}.children)',
+ ],
+ ],
+ [
+ "`Input` / `State` wildcards not in `Output`s",
+ [
+ 'Input 0 ({"b":MATCH}.children)',
+ "has MATCH or ALLSMALLER on key(s) b",
+ 'where Output 0 ({"a":MATCH}.children)',
+ ],
+ ],
+ [
+ "Mismatched `MATCH` wildcards across `Output`s",
+ [
+ 'Output 1 ({"b":ALL,"c":1}.children)',
+ "does not have MATCH wildcards on the same keys as",
+ 'Output 0 ({"b":MATCH}.children).',
+ ],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv007_disallowed_ids(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div()
+
+ @app.callback(
+ Output({"": 1, "a": [4], "c": ALLSMALLER}, "children"),
+ [Input({"b": {"c": 1}}, "children")],
+ )
+ def y(b):
+ return b
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Callback wildcard ID error",
+ [
+ 'Input[0].id["b"] = {"c":1}',
+ "Wildcard callback ID values must be either wildcards",
+ "or constants of one of these types:",
+ "string, number, boolean",
+ ],
+ ],
+ [
+ "Callback wildcard ID error",
+ [
+ 'Output[0].id["c"] = ALLSMALLER',
+ "Allowed wildcards for Outputs are:",
+ "ALL, MATCH",
+ ],
+ ],
+ [
+ "Callback wildcard ID error",
+ [
+ 'Output[0].id["a"] = [4]',
+ "Wildcard callback ID values must be either wildcards",
+ "or constants of one of these types:",
+ "string, number, boolean",
+ ],
+ ],
+ [
+ "Callback wildcard ID error",
+ ['Output[0].id has key ""', "Keys must be non-empty strings."],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def bad_id_app(**kwargs):
+ app = Dash(__name__, **kwargs)
+ app.layout = html.Div(
+ [
+ html.Div(
+ [html.Div(id="inner-div"), dcc.Input(id="inner-input")], id="outer-div"
+ ),
+ dcc.Input(id="outer-input"),
+ ],
+ id="main",
+ )
+
+ @app.callback(Output("nuh-uh", "children"), [Input("inner-input", "value")])
+ def f(a):
+ return a
+
+ @app.callback(Output("outer-input", "value"), [Input("yeah-no", "value")])
+ def g(a):
+ return a
+
+ @app.callback(
+ [Output("inner-div", "children"), Output("nope", "children")],
+ [Input("inner-input", "value")],
+ [State("what", "children")],
+ )
+ def g2(a):
+ return [a, a]
+
+ # the right way
+ @app.callback(Output("inner-div", "style"), [Input("inner-input", "value")])
+ def h(a):
+ return a
+
+ return app
+
+
+# These ones are raised by bad_id_app whether suppressing callback exceptions or not
+dispatch_specs = [
+ [
+ "A nonexistent object was used in an `Input` of a Dash callback. "
+ "The id of this object is `yeah-no` and the property is `value`. "
+ "The string ids in the current layout are: "
+ "[main, outer-div, inner-div, inner-input, outer-input]",
+ [],
+ ],
+ [
+ "A nonexistent object was used in an `Output` of a Dash callback. "
+ "The id of this object is `nope` and the property is `children`. "
+ "The string ids in the current layout are: "
+ "[main, outer-div, inner-div, inner-input, outer-input]",
+ [],
+ ],
+]
+
+
+def test_dvcv008_wrong_callback_id(dash_duo):
+ dash_duo.start_server(bad_id_app(), **debugging)
+
+ specs = [
+ [
+ "ID not found in layout",
+ [
+ "Attempting to connect a callback Input item to component:",
+ '"yeah-no"',
+ "but no components with that id exist in the layout.",
+ "If you are assigning callbacks to components that are",
+ "generated by other callbacks (and therefore not in the",
+ "initial layout), you can suppress this exception by setting",
+ "`suppress_callback_exceptions=True`.",
+ "This ID was used in the callback(s) for Output(s):",
+ "outer-input.value",
+ ],
+ ],
+ [
+ "ID not found in layout",
+ [
+ "Attempting to connect a callback Output item to component:",
+ '"nope"',
+ "but no components with that id exist in the layout.",
+ "This ID was used in the callback(s) for Output(s):",
+ "inner-div.children, nope.children",
+ ],
+ ],
+ [
+ "ID not found in layout",
+ [
+ "Attempting to connect a callback State item to component:",
+ '"what"',
+ "but no components with that id exist in the layout.",
+ "This ID was used in the callback(s) for Output(s):",
+ "inner-div.children, nope.children",
+ ],
+ ],
+ [
+ "ID not found in layout",
+ [
+ "Attempting to connect a callback Output item to component:",
+ '"nuh-uh"',
+ "but no components with that id exist in the layout.",
+ "This ID was used in the callback(s) for Output(s):",
+ "nuh-uh.children",
+ ],
+ ],
+ ]
+ check_errors(dash_duo, dispatch_specs + specs)
+
+
+def test_dvcv009_suppress_callback_exceptions(dash_duo):
+ dash_duo.start_server(bad_id_app(suppress_callback_exceptions=True), **debugging)
+
+ check_errors(dash_duo, dispatch_specs)
+
+
+def test_dvcv010_bad_props(dash_duo):
+ app = Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Div(
+ [html.Div(id="inner-div"), dcc.Input(id="inner-input")], id="outer-div"
+ ),
+ dcc.Input(id={"a": 1}),
+ ],
+ id="main",
+ )
+
+ @app.callback(
+ Output("inner-div", "xyz"),
+ # "data-xyz" is OK, does not give an error
+ [Input("inner-input", "pdq"), Input("inner-div", "data-xyz")],
+ [State("inner-div", "value")],
+ )
+ def xyz(a, b, c):
+ a if b else c
+
+ @app.callback(
+ Output({"a": MATCH}, "no"),
+ [Input({"a": MATCH}, "never")],
+ # "boo" will not error because we don't check State MATCH/ALLSMALLER
+ [State({"a": MATCH}, "boo"), State({"a": ALL}, "nope")],
+ )
+ def f(a, b, c):
+ return a if b else c
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "never" was used with component ID:',
+ '{"a":1}',
+ "in one of the Input items of a callback.",
+ "This ID is assigned to a dash_core_components.Input component",
+ "in the layout, which does not support this property.",
+ "This ID was used in the callback(s) for Output(s):",
+ '{"a":MATCH}.no',
+ ],
+ ],
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "nope" was used with component ID:',
+ '{"a":1}',
+ "in one of the State items of a callback.",
+ "This ID is assigned to a dash_core_components.Input component",
+ '{"a":MATCH}.no',
+ ],
+ ],
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "no" was used with component ID:',
+ '{"a":1}',
+ "in one of the Output items of a callback.",
+ "This ID is assigned to a dash_core_components.Input component",
+ '{"a":MATCH}.no',
+ ],
+ ],
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "pdq" was used with component ID:',
+ '"inner-input"',
+ "in one of the Input items of a callback.",
+ "This ID is assigned to a dash_core_components.Input component",
+ "inner-div.xyz",
+ ],
+ ],
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "value" was used with component ID:',
+ '"inner-div"',
+ "in one of the State items of a callback.",
+ "This ID is assigned to a dash_html_components.Div component",
+ "inner-div.xyz",
+ ],
+ ],
+ [
+ "Invalid prop for this component",
+ [
+ 'Property "xyz" was used with component ID:',
+ '"inner-div"',
+ "in one of the Output items of a callback.",
+ "This ID is assigned to a dash_html_components.Div component",
+ "inner-div.xyz",
+ ],
+ ],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv011_duplicate_outputs_simple(dash_duo):
+ app = Dash(__name__)
+
+ @app.callback(Output("a", "children"), [Input("c", "children")])
+ def c(children):
+ return children
+
+ @app.callback(Output("a", "children"), [Input("b", "children")])
+ def c2(children):
+ return children
+
+ @app.callback([Output("a", "style")], [Input("c", "style")])
+ def s(children):
+ return (children,)
+
+ @app.callback([Output("a", "style")], [Input("b", "style")])
+ def s2(children):
+ return (children,)
+
+ app.layout = html.Div(
+ [
+ html.Div([], id="a"),
+ html.Div(["Bye"], id="b", style={"color": "red"}),
+ html.Div(["Hello"], id="c", style={"color": "green"}),
+ ]
+ )
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ ["Duplicate callback outputs", ["Output 0 (a.children) is already in use."]],
+ ["Duplicate callback outputs", ["Output 0 (a.style) is already in use."]],
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv012_circular_2_step(dash_duo):
+ app = Dash(__name__)
+
+ app.layout = html.Div(
+ [html.Div([], id="a"), html.Div(["Bye"], id="b"), html.Div(["Hello"], id="c")]
+ )
+
+ @app.callback(Output("a", "children"), [Input("b", "children")])
+ def callback(children):
+ return children
+
+ @app.callback(Output("b", "children"), [Input("a", "children")])
+ def c2(children):
+ return children
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Circular Dependencies",
+ [
+ "Dependency Cycle Found:",
+ "a.children -> b.children",
+ "b.children -> a.children",
+ ],
+ ]
+ ]
+ check_errors(dash_duo, specs)
+
+
+def test_dvcv013_circular_3_step(dash_duo):
+ app = Dash(__name__)
+
+ app.layout = html.Div(
+ [html.Div([], id="a"), html.Div(["Bye"], id="b"), html.Div(["Hello"], id="c")]
+ )
+
+ @app.callback(Output("b", "children"), [Input("a", "children")])
+ def callback(children):
+ return children
+
+ @app.callback(Output("c", "children"), [Input("b", "children")])
+ def c2(children):
+ return children
+
+ @app.callback([Output("a", "children")], [Input("c", "children")])
+ def c3(children):
+ return children
+
+ dash_duo.start_server(app, **debugging)
+
+ specs = [
+ [
+ "Circular Dependencies",
+ [
+ "Dependency Cycle Found:",
+ "a.children -> b.children",
+ "b.children -> c.children",
+ "c.children -> a.children",
+ ],
+ ]
+ ]
+ check_errors(dash_duo, specs)
diff --git a/tests/integration/devtools/test_devtools_error_handling.py b/tests/integration/devtools/test_devtools_error_handling.py
index a9773c279e..87be679e5c 100644
--- a/tests/integration/devtools/test_devtools_error_handling.py
+++ b/tests/integration/devtools/test_devtools_error_handling.py
@@ -233,7 +233,7 @@ def test_dveh005_multiple_outputs(dash_duo):
app.layout = html.Div(
[
html.Button(
- id="multi-output", children="trigger multi output update", n_clicks=0,
+ id="multi-output", children="trigger multi output update", n_clicks=0
),
html.Div(id="multi-1"),
html.Div(id="multi-2"),
diff --git a/tests/integration/devtools/test_props_check.py b/tests/integration/devtools/test_props_check.py
index 2b33ed403e..022d687351 100644
--- a/tests/integration/devtools/test_props_check.py
+++ b/tests/integration/devtools/test_props_check.py
@@ -186,7 +186,7 @@ def display_content(pathname):
return "Initial state"
test_case = test_cases[pathname.strip("/")]
return html.Div(
- id="new-component", children=test_case["component"](**test_case["props"]),
+ id="new-component", children=test_case["component"](**test_case["props"])
)
dash_duo.start_server(
diff --git a/tests/integration/renderer/test_dependencies.py b/tests/integration/renderer/test_dependencies.py
index d9d3c7359b..6213d71f7b 100644
--- a/tests/integration/renderer/test_dependencies.py
+++ b/tests/integration/renderer/test_dependencies.py
@@ -40,8 +40,6 @@ def update_output_2(value):
assert output_1_call_count.value == 2 and output_2_call_count.value == 0
- rqs = dash_duo.redux_state_rqs
- assert len(rqs) == 1
- assert rqs[0]["controllerId"] == "output-1.children" and not rqs[0]["rejected"]
+ assert dash_duo.redux_state_rqs == []
assert dash_duo.get_logs() == []
diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py
index 4b6a457fa0..cb44d39fb0 100644
--- a/tests/integration/renderer/test_due_diligence.py
+++ b/tests/integration/renderer/test_due_diligence.py
@@ -79,7 +79,9 @@ def test_rddd001_initial_state(dash_duo):
assert r.status_code == 200
assert r.json() == [], "no dependencies present in app as no callbacks are defined"
- assert dash_duo.redux_state_paths == {
+ paths = dash_duo.redux_state_paths
+ assert paths["objs"] == {}
+ assert paths["strs"] == {
abbr: [
int(token)
if token in string.digits
@@ -92,8 +94,7 @@ def test_rddd001_initial_state(dash_duo):
)
}, "paths should reflect to the component hierarchy"
- rqs = dash_duo.redux_state_rqs
- assert not rqs, "no callback => no requestQueue"
+ assert dash_duo.redux_state_rqs == [], "no callback => no pendingCallbacks"
dash_duo.percy_snapshot(name="layout")
assert dash_duo.get_logs() == [], "console has no errors"
diff --git a/tests/integration/renderer/test_multi_output.py b/tests/integration/renderer/test_multi_output.py
index a9fcf51207..de3e252d3f 100644
--- a/tests/integration/renderer/test_multi_output.py
+++ b/tests/integration/renderer/test_multi_output.py
@@ -134,8 +134,10 @@ def set_bc(a):
dev_tools_hot_reload=False,
)
- # the UI still renders the output triggered by callback
- dash_duo.wait_for_text_to_equal("#c", "X" * 100)
+ # the UI still renders the output triggered by callback.
+ # The new system does NOT loop infinitely like it used to, each callback
+ # is invoked no more than once.
+ dash_duo.wait_for_text_to_equal("#c", "X")
err_text = dash_duo.find_element("span.dash-fe-error__title").text
assert err_text == "Circular Dependencies"
diff --git a/tests/integration/renderer/test_state_and_input.py b/tests/integration/renderer/test_state_and_input.py
index 7ffe1ddbbe..a57aeb7fcb 100644
--- a/tests/integration/renderer/test_state_and_input.py
+++ b/tests/integration/renderer/test_state_and_input.py
@@ -30,8 +30,11 @@ def update_output(input, state):
dash_duo.start_server(app)
- input_ = lambda: dash_duo.find_element("#input")
- output_ = lambda: dash_duo.find_element("#output")
+ def input_():
+ return dash_duo.find_element("#input")
+
+ def output_():
+ return dash_duo.find_element("#output")
assert (
output_().text == 'input="Initial Input", state="Initial State"'
@@ -81,8 +84,11 @@ def update_output(input, n_clicks, state):
dash_duo.start_server(app)
- btn = lambda: dash_duo.find_element("#button")
- output = lambda: dash_duo.find_element("#output")
+ def btn():
+ return dash_duo.find_element("#button")
+
+ def output():
+ return dash_duo.find_element("#output")
assert (
output().text == 'input="Initial Input", state="Initial State"'
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index f1f5c670b2..8476f9fb90 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -1,9 +1,9 @@
import datetime
-import time
from copy import copy
from multiprocessing import Value
from selenium.webdriver.common.keys import Keys
+import flask
import pytest
@@ -15,17 +15,13 @@
import dash_html_components as html
import dash_core_components as dcc
-from dash import Dash, callback_context, no_update
+from dash import Dash, no_update
from dash.dependencies import Input, Output, State
from dash.exceptions import (
PreventUpdate,
- DuplicateCallbackOutput,
- CallbackException,
- MissingCallbackContextException,
InvalidCallbackReturnValue,
IncorrectTypeException,
- NonExistentIdException,
)
from dash.testing.wait import until
@@ -239,7 +235,7 @@ def test_inin006_flow_component(dash_duo):
)
@app.callback(
- Output("output", "children"), [Input("react", "value"), Input("flow", "value")],
+ Output("output", "children"), [Input("react", "value"), Input("flow", "value")]
)
def display_output(react_value, flow_value):
return html.Div(
@@ -376,113 +372,6 @@ def create_layout():
assert dash_duo.find_element("#a").text == "Hello World"
-def test_inin011_multi_output(dash_duo):
- app = Dash(__name__)
-
- app.layout = html.Div(
- [
- html.Button("OUTPUT", id="output-btn"),
- html.Table(
- [
- html.Thead([html.Tr([html.Th("Output 1"), html.Th("Output 2")])]),
- html.Tbody(
- [html.Tr([html.Td(id="output1"), html.Td(id="output2")])]
- ),
- ]
- ),
- html.Div(id="output3"),
- html.Div(id="output4"),
- html.Div(id="output5"),
- ]
- )
-
- @app.callback(
- [Output("output1", "children"), Output("output2", "children")],
- [Input("output-btn", "n_clicks")],
- [State("output-btn", "n_clicks_timestamp")],
- )
- def on_click(n_clicks, n_clicks_timestamp):
- if n_clicks is None:
- raise PreventUpdate
-
- return n_clicks, n_clicks_timestamp
-
- # Dummy callback for DuplicateCallbackOutput test.
- @app.callback(Output("output3", "children"), [Input("output-btn", "n_clicks")])
- def dummy_callback(n_clicks):
- if n_clicks is None:
- raise PreventUpdate
-
- return "Output 3: {}".format(n_clicks)
-
- with pytest.raises(DuplicateCallbackOutput) as err:
-
- @app.callback(Output("output1", "children"), [Input("output-btn", "n_clicks")])
- def on_click_duplicate(n_clicks):
- if n_clicks is None:
- raise PreventUpdate
- return "something else"
-
- pytest.fail("multi output can't be included in a single output")
-
- assert "output1" in err.value.args[0]
-
- with pytest.raises(DuplicateCallbackOutput) as err:
-
- @app.callback(
- [Output("output3", "children"), Output("output4", "children")],
- [Input("output-btn", "n_clicks")],
- )
- def on_click_duplicate_multi(n_clicks):
- if n_clicks is None:
- raise PreventUpdate
- return "something else"
-
- pytest.fail("multi output cannot contain a used single output")
-
- assert "output3" in err.value.args[0]
-
- with pytest.raises(DuplicateCallbackOutput) as err:
-
- @app.callback(
- [Output("output5", "children"), Output("output5", "children")],
- [Input("output-btn", "n_clicks")],
- )
- def on_click_same_output(n_clicks):
- return n_clicks
-
- pytest.fail("same output cannot be used twice in one callback")
-
- assert "output5" in err.value.args[0]
-
- with pytest.raises(DuplicateCallbackOutput) as err:
-
- @app.callback(
- [Output("output1", "children"), Output("output5", "children")],
- [Input("output-btn", "n_clicks")],
- )
- def overlapping_multi_output(n_clicks):
- return n_clicks
-
- pytest.fail("no part of an existing multi-output can be used in another")
- assert (
- "{'output1.children'}" in err.value.args[0]
- or "set(['output1.children'])" in err.value.args[0]
- )
-
- dash_duo.start_server(app)
-
- t = time.time()
-
- btn = dash_duo.find_element("#output-btn")
- btn.click()
- time.sleep(1)
-
- dash_duo.wait_for_text_to_equal("#output1", "1")
-
- assert int(dash_duo.find_element("#output2").text) > t
-
-
def test_inin012_multi_output_no_update(dash_duo):
app = Dash(__name__)
@@ -733,29 +622,6 @@ def update_output(value):
dash_duo.percy_snapshot(name="request-hooks interpolated")
-def test_inin016_modified_response(dash_duo):
- app = Dash(__name__)
- app.layout = html.Div([dcc.Input(id="input", value="ab"), html.Div(id="output")])
-
- @app.callback(Output("output", "children"), [Input("input", "value")])
- def update_output(value):
- callback_context.response.set_cookie("dash cookie", value + " - cookie")
- return value + " - output"
-
- dash_duo.start_server(app)
- dash_duo.wait_for_text_to_equal("#output", "ab - output")
- input1 = dash_duo.find_element("#input")
-
- input1.send_keys("cd")
-
- dash_duo.wait_for_text_to_equal("#output", "abcd - output")
- cookie = dash_duo.driver.get_cookie("dash cookie")
- # cookie gets json encoded
- assert cookie["value"] == '"abcd - cookie"'
-
- assert not dash_duo.get_logs()
-
-
def test_inin017_late_component_register(dash_duo):
app = Dash()
@@ -778,35 +644,6 @@ def update_output(value):
dash_duo.find_element("#inserted-input")
-def test_inin018_output_input_invalid_callback():
- app = Dash(__name__)
- app.layout = html.Div([html.Div("child", id="input-output"), html.Div(id="out")])
-
- with pytest.raises(CallbackException) as err:
-
- @app.callback(
- Output("input-output", "children"), [Input("input-output", "children")],
- )
- def failure(children):
- pass
-
- msg = "Same output and input: input-output.children"
- assert err.value.args[0] == msg
-
- # Multi output version.
- with pytest.raises(CallbackException) as err:
-
- @app.callback(
- [Output("out", "children"), Output("input-output", "children")],
- [Input("input-output", "children")],
- )
- def failure2(children):
- pass
-
- msg = "Same output and input: input-output.children"
- assert err.value.args[0] == msg
-
-
def test_inin019_callback_dep_types():
app = Dash(__name__)
app.layout = html.Div(
@@ -869,116 +706,68 @@ def single(a):
return set([1])
with pytest.raises(InvalidCallbackReturnValue):
- single("aaa")
+ # outputs_list (normally callback_context.outputs_list) is provided
+ # by the dispatcher from the request.
+ single("aaa", outputs_list={"id": "b", "property": "children"})
pytest.fail("not serializable")
@app.callback(
- [Output("c", "children"), Output("d", "children")], [Input("a", "children")],
+ [Output("c", "children"), Output("d", "children")], [Input("a", "children")]
)
def multi(a):
return [1, set([2])]
with pytest.raises(InvalidCallbackReturnValue):
- multi("aaa")
+ outputs_list = [
+ {"id": "c", "property": "children"},
+ {"id": "d", "property": "children"},
+ ]
+ multi("aaa", outputs_list=outputs_list)
pytest.fail("nested non-serializable")
@app.callback(
- [Output("e", "children"), Output("f", "children")], [Input("a", "children")],
+ [Output("e", "children"), Output("f", "children")], [Input("a", "children")]
)
def multi2(a):
return ["abc"]
with pytest.raises(InvalidCallbackReturnValue):
- multi2("aaa")
+ outputs_list = [
+ {"id": "e", "property": "children"},
+ {"id": "f", "property": "children"},
+ ]
+ multi2("aaa", outputs_list=outputs_list)
pytest.fail("wrong-length list")
-def test_inin021_callback_context(dash_duo):
- app = Dash(__name__)
-
- btns = ["btn-{}".format(x) for x in range(1, 6)]
-
- app.layout = html.Div(
- [html.Div([html.Button(btn, id=btn) for btn in btns]), html.Div(id="output")]
- )
-
- @app.callback(Output("output", "children"), [Input(x, "n_clicks") for x in btns])
- def on_click(*args):
- if not callback_context.triggered:
- raise PreventUpdate
- trigger = callback_context.triggered[0]
- return "Just clicked {} for the {} time!".format(
- trigger["prop_id"].split(".")[0], trigger["value"]
- )
-
- dash_duo.start_server(app)
-
- for i in range(1, 5):
- for btn in btns:
- dash_duo.find_element("#" + btn).click()
- dash_duo.wait_for_text_to_equal(
- "#output", "Just clicked {} for the {} time!".format(btn, i)
- )
-
-
-def test_inin022_no_callback_context():
- for attr in ["inputs", "states", "triggered", "response"]:
- with pytest.raises(MissingCallbackContextException):
- getattr(callback_context, attr)
-
-
-def test_inin023_wrong_callback_id():
+def test_inin_024_port_env_success(dash_duo):
app = Dash(__name__)
- app.layout = html.Div(
- [
- html.Div(
- [html.Div(id="inner-div"), dcc.Input(id="inner-input")], id="outer-div"
- ),
- dcc.Input(id="outer-input"),
- ],
- id="main",
- )
-
- ids = ["main", "inner-div", "inner-input", "outer-div", "outer-input"]
-
- with pytest.raises(NonExistentIdException) as err:
-
- @app.callback(Output("nuh-uh", "children"), [Input("inner-input", "value")])
- def f(a):
- return a
+ app.layout = html.Div("hi", "out")
+ dash_duo.start_server(app, port="12345")
+ assert dash_duo.server_url == "http://localhost:12345"
+ dash_duo.wait_for_text_to_equal("#out", "hi")
- assert '"nuh-uh"' in err.value.args[0]
- for component_id in ids:
- assert component_id in err.value.args[0]
- with pytest.raises(NonExistentIdException) as err:
+def nested_app(server, path, text):
+ app = Dash(__name__, server=server, url_base_pathname=path)
+ app.layout = html.Div(id="out")
- @app.callback(Output("inner-div", "children"), [Input("yeah-no", "value")])
- def g(a):
- return a
+ @app.callback(Output("out", "children"), [Input("out", "n_clicks")])
+ def out(n):
+ return text
- assert '"yeah-no"' in err.value.args[0]
- for component_id in ids:
- assert component_id in err.value.args[0]
+ return app
- with pytest.raises(NonExistentIdException) as err:
- @app.callback(
- [Output("inner-div", "children"), Output("nope", "children")],
- [Input("inner-input", "value")],
- )
- def g2(a):
- return [a, a]
+def test_inin025_url_base_pathname(dash_br, dash_thread_server):
+ server = flask.Flask(__name__)
+ app = nested_app(server, "/app1/", "The first")
+ nested_app(server, "/app2/", "The second")
- # the right way
- @app.callback(Output("inner-div", "children"), [Input("inner-input", "value")])
- def h(a):
- return a
+ dash_thread_server(app)
+ dash_br.server_url = "http://localhost:8050/app1/"
+ dash_br.wait_for_text_to_equal("#out", "The first")
-def test_inin_024_port_env_success(dash_duo):
- app = Dash(__name__)
- app.layout = html.Div("hi", "out")
- dash_duo.start_server(app, port="12345")
- assert dash_duo.server_url == "http://localhost:12345"
- dash_duo.wait_for_text_to_equal("#out", "hi")
+ dash_br.server_url = "http://localhost:8050/app2/"
+ dash_br.wait_for_text_to_equal("#out", "The second")
diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py
index fd4d698f0c..ed3bc7a180 100644
--- a/tests/integration/test_render.py
+++ b/tests/integration/test_render.py
@@ -53,18 +53,6 @@ def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT):
),
)
- def request_queue_assertions(self, check_rejected=True, expected_length=None):
- request_queue = self.driver.execute_script(
- "return window.store.getState().requestQueue"
- )
- self.assertTrue(all([(r["status"] == 200) for r in request_queue]))
-
- if check_rejected:
- self.assertTrue(all([(r["rejected"] is False) for r in request_queue]))
-
- if expected_length is not None:
- self.assertEqual(len(request_queue), expected_length)
-
def click_undo(self):
undo_selector = "._dash-undo-redo span:first-child div:last-child"
undo = self.wait_for_element_by_css_selector(undo_selector)
@@ -502,11 +490,10 @@ def update_output(n_clicks):
self.assertEqual(call_count.value, 3)
self.wait_for_text_to_equal("#output1", "2")
self.wait_for_text_to_equal("#output2", "3")
- request_queue = self.driver.execute_script(
- "return window.store.getState().requestQueue"
+ pending_count = self.driver.execute_script(
+ "return window.store.getState().pendingCallbacks.length"
)
- self.assertFalse(request_queue[0]["rejected"])
- self.assertEqual(len(request_queue), 1)
+ self.assertEqual(pending_count, 0)
def test_callbacks_with_shared_grandparent(self):
app = Dash()
@@ -874,17 +861,38 @@ def update_output(value):
self.wait_for_text_to_equal("#output-1", "fire request hooks")
self.wait_for_text_to_equal("#output-pre", "request_pre changed this text!")
- self.wait_for_text_to_equal(
- "#output-pre-payload",
- '{"output":"output-1.children","changedPropIds":["input.value"],"inputs":[{"id":"input","property":"value","value":"fire request hooks"}]}',
- )
self.wait_for_text_to_equal("#output-post", "request_post changed this text!")
- self.wait_for_text_to_equal(
- "#output-post-payload",
- '{"output":"output-1.children","changedPropIds":["input.value"],"inputs":[{"id":"input","property":"value","value":"fire request hooks"}]}',
+ pre_payload = self.wait_for_element_by_css_selector("#output-pre-payload").text
+ post_payload = self.wait_for_element_by_css_selector(
+ "#output-post-payload"
+ ).text
+ post_response = self.wait_for_element_by_css_selector(
+ "#output-post-response"
+ ).text
+ self.assertEqual(
+ json.loads(pre_payload),
+ {
+ "output": "output-1.children",
+ "outputs": {"id": "output-1", "property": "children"},
+ "changedPropIds": ["input.value"],
+ "inputs": [
+ {"id": "input", "property": "value", "value": "fire request hooks"}
+ ],
+ },
+ )
+ self.assertEqual(
+ json.loads(post_payload),
+ {
+ "output": "output-1.children",
+ "outputs": {"id": "output-1", "property": "children"},
+ "changedPropIds": ["input.value"],
+ "inputs": [
+ {"id": "input", "property": "value", "value": "fire request hooks"}
+ ],
+ },
)
- self.wait_for_text_to_equal(
- "#output-post-response", '{"props":{"children":"fire request hooks"}}'
+ self.assertEqual(
+ json.loads(post_response), {"output-1": {"children": "fire request hooks"}}
)
self.percy_snapshot(name="request-hooks render")
diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py
index 2eff1a5660..7fccbc1766 100644
--- a/tests/unit/test_configs.py
+++ b/tests/unit/test_configs.py
@@ -12,11 +12,7 @@
get_combined_config,
load_dash_env_vars,
)
-from dash._utils import (
- get_asset_path,
- get_relative_path,
- strip_relative_path,
-)
+from dash._utils import get_asset_path, get_relative_path, strip_relative_path
@pytest.fixture