diff --git a/CHANGELOG.md b/CHANGELOG.md index f28986d9f8..5b9baeaaf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,22 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [1.10.0] - 2020-04-01 ### Added +- [#1103](https://github.com/plotly/dash/pull/1103) Wildcard IDs and callbacks. Component IDs can be dictionaries, and callbacks can reference patterns of components, using three different wildcards: `ALL`, `MATCH`, and `ALLSMALLER`, available from `dash.dependencies`. This lets you create components on demand, and have callbacks respond to any and all of them. To help with this, `dash.callback_context` gets three new entries: `outputs_list`, `inputs_list`, and `states_list`, which contain all the ids, properties, and except for the outputs, the property values from all matched components. +- [#1103](https://github.com/plotly/dash/pull/1103) `dash.testing` option `--pause`: after opening the dash app in a test, will invoke `pdb` for live debugging of both Javascript and Python. Use with a single test case like `pytest -k cbwc001 --pause`. + - [#1134](https://github.com/plotly/dash/pull/1134) Allow `dash.run_server()` host and port parameters to be set with environment variables HOST & PORT, respectively ### Changed +- [#1103](https://github.com/plotly/dash/pull/1103) Multiple changes to the callback pipeline: + - `dash.callback_context.triggered` now does NOT reflect any initial values, and DOES reflect EVERY value which has been changed either by activity in the app or as a result of a previous callback. That means that the initial call of a callback with no prerequisite callbacks will list nothing as triggering. For backward compatibility, we continue to provide a length-1 list for `triggered`, but its `id` and `property` are blank strings, and `bool(triggered)` is `False`. + - A user interaction which returns the same property value as was previously present will not trigger the component to re-render, nor trigger callbacks using that property as an input. + - Callback validation is now mostly done in the browser, rather than in Python. A few things - mostly type validation, like ensuring IDs are strings or dicts and properties are strings - are still done in Python, but most others, like ensuring outputs are unique, inputs and outputs don't overlap, and (if desired) that IDs are present in the layout, are done in the browser. This means you can define callbacks BEFORE the layout and still validate IDs to the layout; and while developing an app, most errors in callback definitions will not halt the app. + - [#1145](https://github.com/plotly/dash/pull/1145) Update from React 16.8.6 to 16.13.0 ### Fixed +- [#1103](https://github.com/plotly/dash/pull/1103) Fixed multiple bugs with chained callbacks either not triggering, inconsistently triggering, or triggering multiple times. This includes: [#635](https://github.com/plotly/dash/issues/635), [#832](https://github.com/plotly/dash/issues/832), [#1053](https://github.com/plotly/dash/issues/1053), [#1071](https://github.com/plotly/dash/issues/1071), and [#1084](https://github.com/plotly/dash/issues/1084). Also fixed [#1105](https://github.com/plotly/dash/issues/1105): async components that aren't rendered by the page (for example in a background Tab) would block the app from executing callbacks. + - [#1142](https://github.com/plotly/dash/pull/1142) [Persistence](https://dash.plot.ly/persistence): Also persist 0, empty string etc ## [1.9.1] - 2020-02-27 diff --git a/dash-renderer/package-lock.json b/dash-renderer/package-lock.json index 10dc4dc803..ab07b94c3e 100644 --- a/dash-renderer/package-lock.json +++ b/dash-renderer/package-lock.json @@ -7477,6 +7477,14 @@ } } }, + "fast-isnumeric": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.3.tgz", + "integrity": "sha512-MdojHkfLx8pjRNZyGjOhX4HxNPaf0l5R/v5rGZ1bGXCnRPyQIUAe4I1H7QtrlUwuuiDHKdpQTjT3lmueVH2otw==", + "requires": { + "is-string-blank": "^1.0.1" + } + }, "fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", @@ -9583,6 +9591,11 @@ "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", "dev": true }, + "is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==" + }, "is-supported-regexp-flag": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.1.tgz", @@ -12129,6 +12142,12 @@ "readable-stream": "^2.0.1" } }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=", + "dev": true + }, "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", @@ -12754,6 +12773,63 @@ "integrity": "sha1-0LFF62kRicY6eNIB3E/bEpPvDAM=", "dev": true }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + } + } + }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -13538,6 +13614,12 @@ "integrity": "sha512-OYMyqkKzK7blWO/+XZYP6w8hH0LDvkBvdvKukti+7kqYFCiEAk+gI3DWnryapc0Dau05ugGTy0foQ6mqn4AHYA==", "dev": true }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -16134,6 +16216,12 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, + "shell-quote": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", + "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "dev": true + }, "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", @@ -16824,6 +16912,188 @@ } } }, + "string.prototype.padend": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.0.tgz", + "integrity": "sha512-3aIv8Ffdp8EZj8iLwREGpQaUZiPyrWrpzMBHvkiSW/bK/EGve9np07Vwy7IJ5waydpGXzQZu/F8Oze2/IWkBaA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", + "dev": true + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, + "string.prototype.trimleft": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" + } + }, + "string.prototype.trimright": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + } + } + } + }, + "string.prototype.trimend": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz", + "integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", + "dev": true + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, + "string.prototype.trimleft": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" + } + }, + "string.prototype.trimright": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + } + } + } + }, "string.prototype.trimleft": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", @@ -16844,6 +17114,97 @@ "function-bind": "^1.1.1" } }, + "string.prototype.trimstart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz", + "integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", + "dev": true + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, + "string.prototype.trimleft": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" + } + }, + "string.prototype.trimright": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + } + } + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/dash-renderer/package.json b/dash-renderer/package.json index 306bbe049f..89e96e08cc 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -24,6 +24,7 @@ "@babel/polyfill": "7.8.7", "cookie": "^0.4.0", "dependency-graph": "^0.9.0", + "fast-isnumeric": "^1.1.3", "prop-types": "15.7.2", "radium": "^0.26.0", "ramda": "^0.27.0", @@ -54,6 +55,7 @@ "eslint-plugin-prettier": "^3.1.2", "eslint-plugin-react": "^7.18.3", "jest": "^25.1.0", + "npm-run-all": "^4.1.5", "prettier": "^1.19.1", "prettier-eslint": "^9.0.1", "prettier-eslint-cli": "^5.0.0", diff --git a/dash-renderer/src/APIController.react.js b/dash-renderer/src/APIController.react.js index c149ca1595..23e5f34c01 100644 --- a/dash-renderer/src/APIController.react.js +++ b/dash-renderer/src/APIController.react.js @@ -1,17 +1,22 @@ import {connect} from 'react-redux'; -import {includes, isEmpty, isNil} from 'ramda'; -import React, {useEffect, useState} from 'react'; +import {includes, isEmpty} from 'ramda'; +import React, {useEffect, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import TreeContainer from './TreeContainer'; import GlobalErrorContainer from './components/error/GlobalErrorContainer.react'; import { - computeGraphs, - computePaths, + dispatchError, hydrateInitialOutputs, + onError, + setGraphs, + setPaths, setLayout, -} from './actions/index'; -import {applyPersistence} from './persistence'; +} from './actions'; +import {computePaths} from './actions/paths'; +import {computeGraphs} from './actions/dependencies'; import apiThunk from './actions/api'; +import {EventEmitter} from './actions/utils'; +import {applyPersistence} from './persistence'; import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; @@ -23,61 +28,18 @@ import {STATUS} from './constants/constants'; const UnconnectedContainer = props => { const [errorLoading, setErrorLoading] = useState(false); - useEffect(() => { - const { - appLifecycle, - dependenciesRequest, - dispatch, - graphs, - layout, - layoutRequest, - paths, - } = props; - - if (isEmpty(layoutRequest)) { - dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); - } else if (layoutRequest.status === STATUS.OK) { - if (isEmpty(layout)) { - const finalLayout = applyPersistence( - layoutRequest.content, - dispatch - ); - dispatch(setLayout(finalLayout)); - } else if (isNil(paths)) { - dispatch(computePaths({subTree: layout, startingPath: []})); - } - } + const events = useRef(null); + if (!events.current) { + events.current = new EventEmitter(); + } + const renderedTree = useRef(false); - if (isEmpty(dependenciesRequest)) { - dispatch( - apiThunk('_dash-dependencies', 'GET', 'dependenciesRequest') - ); - } else if ( - dependenciesRequest.status === STATUS.OK && - isEmpty(graphs) - ) { - dispatch(computeGraphs(dependenciesRequest.content)); - } + useEffect(storeEffect.bind(null, props, events, setErrorLoading)); - if ( - // dependenciesRequest and its computed stores - dependenciesRequest.status === STATUS.OK && - !isEmpty(graphs) && - // LayoutRequest and its computed stores - layoutRequest.status === STATUS.OK && - !isEmpty(layout) && - !isNil(paths) && - // Hasn't already hydrated - appLifecycle === getAppState('STARTED') - ) { - let error = false; - try { - dispatch(hydrateInitialOutputs()); - } catch (err) { - error = true; - } finally { - setErrorLoading(error); - } + useEffect(() => { + if (renderedTree.current) { + renderedTree.current = false; + events.current.emit('rendered'); } }); @@ -89,38 +51,102 @@ const UnconnectedContainer = props => { config, } = props; + let content; if ( layoutRequest.status && !includes(layoutRequest.status, [STATUS.OK, 'loading']) ) { - return
Error loading layout
; + content =
Error loading layout
; } else if ( errorLoading || (dependenciesRequest.status && !includes(dependenciesRequest.status, [STATUS.OK, 'loading'])) ) { - return
Error loading dependencies
; - } else if (appLifecycle === getAppState('HYDRATED') && config.ui === true) { - return ( - - - - ); + content =
Error loading dependencies
; } else if (appLifecycle === getAppState('HYDRATED')) { - return ( + renderedTree.current = true; + content = ( ); + } else { + content =
Loading...
; } - return
Loading...
; + return config && config.ui === true ? ( + {content} + ) : ( + content + ); }; +function storeEffect(props, events, setErrorLoading) { + const { + appLifecycle, + dependenciesRequest, + dispatch, + error, + graphs, + layout, + layoutRequest, + } = props; + + if (isEmpty(layoutRequest)) { + dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); + } else if (layoutRequest.status === STATUS.OK) { + if (isEmpty(layout)) { + const finalLayout = applyPersistence( + layoutRequest.content, + dispatch + ); + dispatch( + setPaths(computePaths(finalLayout, [], null, events.current)) + ); + dispatch(setLayout(finalLayout)); + } + } + + if (isEmpty(dependenciesRequest)) { + dispatch(apiThunk('_dash-dependencies', 'GET', 'dependenciesRequest')); + } else if (dependenciesRequest.status === STATUS.OK && isEmpty(graphs)) { + dispatch( + setGraphs( + computeGraphs( + dependenciesRequest.content, + dispatchError(dispatch) + ) + ) + ); + } + + if ( + // dependenciesRequest and its computed stores + dependenciesRequest.status === STATUS.OK && + !isEmpty(graphs) && + // LayoutRequest and its computed stores + layoutRequest.status === STATUS.OK && + !isEmpty(layout) && + // Hasn't already hydrated + appLifecycle === getAppState('STARTED') + ) { + let hasError = false; + try { + dispatch(hydrateInitialOutputs(dispatchError(dispatch))); + } catch (err) { + // Display this error in devtools, unless we have errors + // already, in which case we assume this new one is moot + if (!error.frontEnd.length && !error.backEnd.length) { + dispatch(onError({type: 'backEnd', error: err})); + } + hasError = true; + } finally { + setErrorLoading(hasError); + } + } +} + UnconnectedContainer.propTypes = { appLifecycle: PropTypes.oneOf([ getAppState('STARTED'), @@ -131,7 +157,6 @@ UnconnectedContainer.propTypes = { graphs: PropTypes.object, layoutRequest: PropTypes.object, layout: PropTypes.object, - paths: PropTypes.object, history: PropTypes.any, error: PropTypes.object, config: PropTypes.object, @@ -145,7 +170,6 @@ const Container = connect( layoutRequest: state.layoutRequest, layout: state.layout, graphs: state.graphs, - paths: state.paths, history: state.history, error: state.error, config: state.config, diff --git a/dash-renderer/src/TreeContainer.js b/dash-renderer/src/TreeContainer.js index 8713dc5d7c..3b1c3c13c2 100644 --- a/dash-renderer/src/TreeContainer.js +++ b/dash-renderer/src/TreeContainer.js @@ -5,19 +5,18 @@ import {propTypeErrorHandler} from './exceptions'; import {connect} from 'react-redux'; import { addIndex, - any, concat, + dissoc, + equals, filter, - forEach, has, - includes, isEmpty, isNil, - keysIn, + keys, map, mergeRight, - omit, pick, + pickBy, propOr, type, } from 'ramda'; @@ -26,6 +25,7 @@ import isSimpleComponent from './isSimpleComponent'; import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; import checkPropTypes from './checkPropTypes'; +import {getWatchedKeys, stringifyId} from './actions/dependencies'; function validateComponent(componentDefinition) { if (type(componentDefinition) === 'Array') { @@ -33,7 +33,7 @@ function validateComponent(componentDefinition) { 'The children property of a component is a list of lists, instead ' + 'of just a list. ' + 'Check the component that has the following contents, ' + - 'and remove of the levels of nesting: \n' + + 'and remove one of the levels of nesting: \n' + JSON.stringify(componentDefinition, null, 2) ); } @@ -59,7 +59,9 @@ const createContainer = (component, path) => component ) : ( @@ -78,11 +80,7 @@ function CheckedComponent(p) { propTypeErrorHandler(errorMessage, props, type); } - return React.createElement( - element, - mergeRight(props, extraProps), - ...(Array.isArray(children) ? children : [children]) - ); + return createElement(element, props, extraProps, children); } CheckedComponent.propTypes = { @@ -93,6 +91,15 @@ CheckedComponent.propTypes = { extraProps: PropTypes.any, id: PropTypes.string, }; + +function createElement(element, props, extraProps, children) { + const allProps = mergeRight(props, extraProps); + if (Array.isArray(children)) { + return React.createElement(element, allProps, ...children); + } + return React.createElement(element, allProps, children); +} + class TreeContainer extends Component { constructor(props) { super(props); @@ -102,49 +109,47 @@ class TreeContainer extends Component { setProps(newProps) { const { - _dashprivate_dependencies, + _dashprivate_graphs, _dashprivate_dispatch, _dashprivate_path, _dashprivate_layout, } = this.props; - const id = this.getLayoutProps().id; - - // Identify the modified props that are required for callbacks - const watchedKeys = filter( - key => - _dashprivate_dependencies && - _dashprivate_dependencies.find( - dependency => - dependency.inputs.find( - input => input.id === id && input.property === key - ) || - dependency.state.find( - state => state.id === id && state.property === key - ) - ) - )(keysIn(newProps)); - - // setProps here is triggered by the UI - record these changes - // for persistence - recordUiEdit(_dashprivate_layout, newProps, _dashprivate_dispatch); - - // Always update this component's props - _dashprivate_dispatch( - updateProps({ - props: newProps, - itempath: _dashprivate_path, - }) + const oldProps = this.getLayoutProps(); + const {id} = oldProps; + const changedProps = pickBy( + (val, key) => !equals(val, oldProps[key]), + newProps ); + if (!isEmpty(changedProps)) { + // Identify the modified props that are required for callbacks + const watchedKeys = getWatchedKeys( + id, + keys(changedProps), + _dashprivate_graphs + ); + + // setProps here is triggered by the UI - record these changes + // for persistence + recordUiEdit(_dashprivate_layout, newProps, _dashprivate_dispatch); - // Only dispatch changes to Dash if a watched prop changed - if (watchedKeys.length) { + // Always update this component's props _dashprivate_dispatch( - notifyObservers({ - id: id, - props: pick(watchedKeys)(newProps), + updateProps({ + props: changedProps, + itempath: _dashprivate_path, }) ); + + // Only dispatch changes to Dash if a watched prop changed + if (watchedKeys.length) { + _dashprivate_dispatch( + notifyObservers({ + id, + props: pick(watchedKeys, changedProps), + }) + ); + } } } @@ -179,32 +184,32 @@ class TreeContainer extends Component { const element = Registry.resolve(_dashprivate_layout); - const props = omit(['children'], _dashprivate_layout.props); + const props = dissoc('children', _dashprivate_layout.props); - return _dashprivate_config.props_check ? ( - - - - ) : ( + if (type(props.id) === 'Object') { + // Turn object ids (for wildcards) into unique strings. + // Because of the `dissoc` above we're not mutating the layout, + // just the id we pass on to the rendered component + props.id = stringifyId(props.id); + } + const extraProps = {loading_state, setProps}; + + return ( - {React.createElement( - element, - mergeRight(props, {loading_state, setProps}), - ...(Array.isArray(children) ? children : [children]) + {_dashprivate_config.props_check ? ( + + ) : ( + createElement(element, props, extraProps, children) )} ); @@ -247,11 +252,10 @@ class TreeContainer extends Component { } TreeContainer.propTypes = { - _dashprivate_dependencies: PropTypes.any, + _dashprivate_graphs: PropTypes.any, _dashprivate_dispatch: PropTypes.func, _dashprivate_layout: PropTypes.object, _dashprivate_loadingState: PropTypes.object, - _dashprivate_requestQueue: PropTypes.any, _dashprivate_config: PropTypes.object, _dashprivate_path: PropTypes.array, }; @@ -294,28 +298,34 @@ function getNestedIds(layout) { return ids; } -function getLoadingState(layout, requestQueue) { +function getLoadingState(layout, pendingCallbacks) { const ids = isLoadingComponent(layout) ? getNestedIds(layout) - : layout && layout.props.id - ? [layout.props.id] - : []; + : layout && layout.props.id && [layout.props.id]; let isLoading = false; let loadingProp; let loadingComponent; - if (requestQueue) { - forEach(r => { - const controllerId = isNil(r.controllerId) ? '' : r.controllerId; - if ( - r.status === 'loading' && - any(id => includes(id, controllerId), ids) - ) { - isLoading = true; - [loadingComponent, loadingProp] = r.controllerId.split('.'); + if (pendingCallbacks && pendingCallbacks.length && ids && ids.length) { + const idStrs = ids.map(stringifyId); + + pendingCallbacks.forEach(cb => { + const {requestId, requestedOutputs} = cb; + if (requestId === undefined) { + return; } - }, requestQueue); + + idStrs.forEach(idStr => { + const props = requestedOutputs[idStr]; + if (props) { + isLoading = true; + // TODO: what about multiple loading components / props? + loadingComponent = idStr; + loadingProp = props[0]; + } + }); + }); } // Set loading state @@ -328,21 +338,20 @@ function getLoadingState(layout, requestQueue) { export const AugmentedTreeContainer = connect( state => ({ - dependencies: state.dependenciesRequest.content, - requestQueue: state.requestQueue, + graphs: state.graphs, + pendingCallbacks: state.pendingCallbacks, config: state.config, }), dispatch => ({dispatch}), (stateProps, dispatchProps, ownProps) => ({ - _dashprivate_dependencies: stateProps.dependencies, + _dashprivate_graphs: stateProps.graphs, _dashprivate_dispatch: dispatchProps.dispatch, _dashprivate_layout: ownProps._dashprivate_layout, _dashprivate_path: ownProps._dashprivate_path, _dashprivate_loadingState: getLoadingState( ownProps._dashprivate_layout, - stateProps.requestQueue + stateProps.pendingCallbacks ), - _dashprivate_requestQueue: stateProps.requestQueue, _dashprivate_config: stateProps.config, }) )(TreeContainer); diff --git a/dash-renderer/src/actions/api.js b/dash-renderer/src/actions/api.js index 4ab3891fde..b04d8c4d64 100644 --- a/dash-renderer/src/actions/api.js +++ b/dash-renderer/src/actions/api.js @@ -1,6 +1,6 @@ import {mergeDeepRight} from 'ramda'; import {handleAsyncError, getCSRFHeader} from '../actions'; -import {urlBase} from '../utils'; +import {urlBase} from './utils'; function GET(path, fetchConfig) { return fetch( diff --git a/dash-renderer/src/actions/constants.js b/dash-renderer/src/actions/constants.js index 37866de19d..b9fba5047d 100644 --- a/dash-renderer/src/actions/constants.js +++ b/dash-renderer/src/actions/constants.js @@ -1,18 +1,18 @@ const actionList = { - ON_PROP_CHANGE: 'ON_PROP_CHANGE', - SET_REQUEST_QUEUE: 'SET_REQUEST_QUEUE', - COMPUTE_GRAPHS: 'COMPUTE_GRAPHS', - COMPUTE_PATHS: 'COMPUTE_PATHS', - SET_LAYOUT: 'SET_LAYOUT', - SET_APP_LIFECYCLE: 'SET_APP_LIFECYCLE', - SET_CONFIG: 'SET_CONFIG', - ON_ERROR: 'ON_ERROR', - SET_HOOKS: 'SET_HOOKS', + ON_PROP_CHANGE: 1, + SET_REQUEST_QUEUE: 1, + SET_GRAPHS: 1, + SET_PATHS: 1, + SET_LAYOUT: 1, + SET_APP_LIFECYCLE: 1, + SET_CONFIG: 1, + ON_ERROR: 1, + SET_HOOKS: 1, }; export const getAction = action => { if (actionList[action]) { - return actionList[action]; + return action; } throw new Error(`${action} is not defined.`); }; diff --git a/dash-renderer/src/actions/dependencies.js b/dash-renderer/src/actions/dependencies.js new file mode 100644 index 0000000000..02d046c2a8 --- /dev/null +++ b/dash-renderer/src/actions/dependencies.js @@ -0,0 +1,1545 @@ +import {DepGraph} from 'dependency-graph'; +import isNumeric from 'fast-isnumeric'; +import { + all, + any, + ap, + assoc, + clone, + difference, + dissoc, + equals, + evolve, + flatten, + forEachObjIndexed, + includes, + intersection, + isEmpty, + keys, + map, + mergeDeepRight, + mergeRight, + mergeWith, + partition, + path, + pickBy, + pluck, + propEq, + props, + startsWith, + unnest, + values, + zip, + zipObj, +} from 'ramda'; + +const mergeMax = mergeWith(Math.max); + +import {getPath} from './paths'; + +import {crawlLayout} from './utils'; + +import Registry from '../registry'; + +/* + * 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.." + */ +export const isMultiOutputProp = idAndProp => idAndProp.startsWith('..'); + +const ALL = {wild: 'ALL', multi: 1}; +const MATCH = {wild: 'MATCH'}; +const ALLSMALLER = {wild: 'ALLSMALLER', multi: 1, expand: 1}; +const wildcards = {ALL, MATCH, ALLSMALLER}; +const allowedWildcards = { + Output: {ALL, MATCH}, + Input: wildcards, + State: wildcards, +}; +const wildcardValTypes = ['string', 'number', 'boolean']; + +const idInvalidChars = ['.', '{']; + +/* + * If this ID is a wildcard, it is a stringified JSON object + * the "{" character is disallowed from regular string IDs + */ +const isWildcardId = idStr => idStr.startsWith('{'); + +/* + * Turn stringified wildcard IDs into objects. + * Wildcards are encoded as single-item arrays containing the wildcard name + * as a string. + */ +function parseWildcardId(idStr) { + return map( + val => (Array.isArray(val) && wildcards[val[0]]) || val, + JSON.parse(idStr) + ); +} + +/* + * 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.." + */ +function parseMultipleOutputs(outputIdAndProp) { + return outputIdAndProp.substr(2, outputIdAndProp.length - 4).split('...'); +} + +function splitIdAndProp(idAndProp) { + // since wildcard ids can have . in them but props can't, + // look for the last . in the string and split there + const dotPos = idAndProp.lastIndexOf('.'); + const idStr = idAndProp.substr(0, dotPos); + return { + id: parseIfWildcard(idStr), + property: idAndProp.substr(dotPos + 1), + }; +} + +/* + * Check if this ID is a stringified object, and if so parse it to that object + */ +export function parseIfWildcard(idStr) { + return isWildcardId(idStr) ? parseWildcardId(idStr) : idStr; +} + +export const combineIdAndProp = ({id, property}) => + `${stringifyId(id)}.${property}`; + +/* + * JSON.stringify - for the object form - but ensuring keys are sorted + */ +export function stringifyId(id) { + if (typeof id !== 'object') { + return id; + } + const stringifyVal = v => (v && v.wild) || JSON.stringify(v); + const parts = Object.keys(id) + .sort() + .map(k => JSON.stringify(k) + ':' + stringifyVal(id[k])); + return '{' + parts.join(',') + '}'; +} + +/* + * id dict values can be numbers, strings, and booleans. + * We need a definite ordering that will work across types, + * even if sane users would not mix types. + * - numeric strings are treated as numbers + * - booleans come after numbers, before strings. false, then true. + * - non-numeric strings come last + */ +function idValSort(a, b) { + const bIsNumeric = isNumeric(b); + if (isNumeric(a)) { + if (bIsNumeric) { + const aN = Number(a); + const bN = Number(b); + return aN > bN ? 1 : aN < bN ? -1 : 0; + } + return -1; + } + if (bIsNumeric) { + return 1; + } + const aIsBool = typeof a === 'boolean'; + if (aIsBool !== (typeof b === 'boolean')) { + return aIsBool ? -1 : 1; + } + return a > b ? 1 : a < b ? -1 : 0; +} + +/* + * Provide a value known to be before or after v, according to idValSort + */ +const valBefore = v => (isNumeric(v) ? v - 1 : 0); +const valAfter = v => (typeof v === 'string' ? v + 'z' : 'z'); + +function addMap(depMap, id, prop, dependency) { + const idMap = (depMap[id] = depMap[id] || {}); + const callbacks = (idMap[prop] = idMap[prop] || []); + callbacks.push(dependency); +} + +function addPattern(depMap, idSpec, prop, dependency) { + const keys = Object.keys(idSpec).sort(); + const keyStr = keys.join(','); + const values = props(keys, idSpec); + const keyCallbacks = (depMap[keyStr] = depMap[keyStr] || {}); + const propCallbacks = (keyCallbacks[prop] = keyCallbacks[prop] || []); + let valMatch = false; + for (let i = 0; i < propCallbacks.length; i++) { + if (equals(values, propCallbacks[i].values)) { + valMatch = propCallbacks[i]; + break; + } + } + if (!valMatch) { + valMatch = {keys, values, callbacks: []}; + propCallbacks.push(valMatch); + } + valMatch.callbacks.push(dependency); +} + +function validateDependencies(parsedDependencies, dispatchError) { + const outStrs = {}; + const outObjs = []; + + parsedDependencies.forEach(dep => { + const {inputs, outputs, state} = dep; + let hasOutputs = true; + if (outputs.length === 1 && !outputs[0].id && !outputs[0].property) { + hasOutputs = false; + dispatchError('A callback is missing Outputs', [ + 'Please provide an output for this callback:', + JSON.stringify(dep, null, 2), + ]); + } + + const head = + 'In the callback for output(s):\n ' + + outputs.map(combineIdAndProp).join('\n '); + + if (!inputs.length) { + dispatchError('A callback is missing Inputs', [ + head, + 'there are no `Input` elements.', + 'Without `Input` elements, it will never get called.', + '', + 'Subscribing to `Input` components will cause the', + 'callback to be called whenever their values change.', + ]); + } + + const spec = [ + [outputs, 'Output'], + [inputs, 'Input'], + [state, 'State'], + ]; + spec.forEach(([args, cls]) => { + if (cls === 'Output' && !hasOutputs) { + // just a quirk of how we pass & parse outputs - if you don't + // provide one, it looks like a single blank output. This is + // actually useful for graceful failure, so we work around it. + return; + } + + if (!Array.isArray(args)) { + dispatchError(`Callback ${cls}(s) must be an Array`, [ + head, + `For ${cls}(s) we found:`, + JSON.stringify(args), + 'but we expected an Array.', + ]); + } + args.forEach((idProp, i) => { + validateArg(idProp, head, cls, i, dispatchError); + }); + }); + + findDuplicateOutputs(outputs, head, dispatchError, outStrs, outObjs); + findInOutOverlap(outputs, inputs, head, dispatchError); + findMismatchedWildcards(outputs, inputs, state, head, dispatchError); + }); +} + +function validateArg({id, property}, head, cls, i, dispatchError) { + if (typeof property !== 'string' || !property) { + dispatchError('Callback property error', [ + head, + `${cls}[${i}].property = ${JSON.stringify(property)}`, + 'but we expected `property` to be a non-empty string.', + ]); + } + + if (typeof id === 'object') { + if (isEmpty(id)) { + dispatchError('Callback item missing ID', [ + head, + `${cls}[${i}].id = {}`, + 'Every item linked to a callback needs an ID', + ]); + } + + forEachObjIndexed((v, k) => { + if (!k) { + dispatchError('Callback wildcard ID error', [ + head, + `${cls}[${i}].id has key "${k}"`, + 'Keys must be non-empty strings.', + ]); + } + + if (typeof v === 'object' && v.wild) { + if (allowedWildcards[cls][v.wild] !== v) { + dispatchError('Callback wildcard ID error', [ + head, + `${cls}[${i}].id["${k}"] = ${v.wild}`, + `Allowed wildcards for ${cls}s are:`, + keys(allowedWildcards[cls]).join(', '), + ]); + } + } else if (!includes(typeof v, wildcardValTypes)) { + dispatchError('Callback wildcard ID error', [ + head, + `${cls}[${i}].id["${k}"] = ${JSON.stringify(v)}`, + 'Wildcard callback ID values must be either wildcards', + 'or constants of one of these types:', + wildcardValTypes.join(', '), + ]); + } + }, id); + } else if (typeof id === 'string') { + if (!id) { + dispatchError('Callback item missing ID', [ + head, + `${cls}[${i}].id = "${id}"`, + 'Every item linked to a callback needs an ID', + ]); + } + const invalidChars = idInvalidChars.filter(c => includes(c, id)); + if (invalidChars.length) { + dispatchError('Callback invalid ID string', [ + head, + `${cls}[${i}].id = '${id}'`, + `characters '${invalidChars.join("', '")}' are not allowed.`, + ]); + } + } else { + dispatchError('Callback ID type error', [ + head, + `${cls}[${i}].id = ${JSON.stringify(id)}`, + 'IDs must be strings or wildcard-compatible objects.', + ]); + } +} + +function findDuplicateOutputs(outputs, head, dispatchError, outStrs, outObjs) { + const newOutputStrs = {}; + const newOutputObjs = []; + outputs.forEach(({id, property}, i) => { + if (typeof id === 'string') { + const idProp = combineIdAndProp({id, property}); + if (newOutputStrs[idProp]) { + dispatchError('Duplicate callback Outputs', [ + head, + `Output ${i} (${idProp}) is already used by this callback.`, + ]); + } else if (outStrs[idProp]) { + dispatchError('Duplicate callback outputs', [ + head, + `Output ${i} (${idProp}) is already in use.`, + 'Any given output can only have one callback that sets it.', + 'To resolve this situation, try combining these into', + 'one callback function, distinguishing the trigger', + 'by using `dash.callback_context` if necessary.', + ]); + } else { + newOutputStrs[idProp] = 1; + } + } else { + const idObj = {id, property}; + const selfOverlap = wildcardOverlap(idObj, newOutputObjs); + const otherOverlap = selfOverlap || wildcardOverlap(idObj, outObjs); + if (selfOverlap || otherOverlap) { + const idProp = combineIdAndProp(idObj); + const idProp2 = combineIdAndProp(selfOverlap || otherOverlap); + dispatchError('Overlapping wildcard callback outputs', [ + head, + `Output ${i} (${idProp})`, + `overlaps another output (${idProp2})`, + `used in ${selfOverlap ? 'this' : 'a different'} callback.`, + ]); + } else { + newOutputObjs.push(idObj); + } + } + }); + keys(newOutputStrs).forEach(k => { + outStrs[k] = 1; + }); + newOutputObjs.forEach(idObj => { + outObjs.push(idObj); + }); +} + +function findInOutOverlap(outputs, inputs, head, dispatchError) { + outputs.forEach((out, outi) => { + const {id: outId, property: outProp} = out; + inputs.forEach((in_, ini) => { + const {id: inId, property: inProp} = in_; + if (outProp !== inProp || typeof outId !== typeof inId) { + return; + } + if (typeof outId === 'string') { + if (outId === inId) { + dispatchError('Same `Input` and `Output`', [ + head, + `Input ${ini} (${combineIdAndProp(in_)})`, + `matches Output ${outi} (${combineIdAndProp(out)})`, + ]); + } + } else if (wildcardOverlap(in_, [out])) { + dispatchError('Same `Input` and `Output`', [ + head, + `Input ${ini} (${combineIdAndProp(in_)})`, + 'can match the same component(s) as', + `Output ${outi} (${combineIdAndProp(out)})`, + ]); + } + }); + }); +} + +function findMismatchedWildcards(outputs, inputs, state, head, dispatchError) { + const {anyKeys: out0AnyKeys} = findWildcardKeys(outputs[0].id); + outputs.forEach((out, outi) => { + if (outi && !equals(findWildcardKeys(out.id).anyKeys, out0AnyKeys)) { + dispatchError('Mismatched `MATCH` wildcards across `Output`s', [ + head, + `Output ${outi} (${combineIdAndProp(out)})`, + 'does not have MATCH wildcards on the same keys as', + `Output 0 (${combineIdAndProp(outputs[0])}).`, + 'MATCH wildcards must be on the same keys for all Outputs.', + 'ALL wildcards need not match, only MATCH.', + ]); + } + }); + [ + [inputs, 'Input'], + [state, 'State'], + ].forEach(([args, cls]) => { + args.forEach((arg, i) => { + const {anyKeys, allsmallerKeys} = findWildcardKeys(arg.id); + const allWildcardKeys = anyKeys.concat(allsmallerKeys); + const diff = difference(allWildcardKeys, out0AnyKeys); + if (diff.length) { + diff.sort(); + dispatchError('`Input` / `State` wildcards not in `Output`s', [ + head, + `${cls} ${i} (${combineIdAndProp(arg)})`, + `has MATCH or ALLSMALLER on key(s) ${diff.join(', ')}`, + `where Output 0 (${combineIdAndProp(outputs[0])})`, + 'does not have a MATCH wildcard. Inputs and State do not', + 'need every MATCH from the Output(s), but they cannot have', + 'extras beyond the Output(s).', + ]); + } + }); + }); +} + +const matchWildKeys = ([a, b]) => { + const aWild = a && a.wild; + const bWild = b && b.wild; + if (aWild && bWild) { + // Every wildcard combination overlaps except MATCH<->ALLSMALLER + return !( + (a === MATCH && b === ALLSMALLER) || + (a === ALLSMALLER && b === MATCH) + ); + } + return a === b || aWild || bWild; +}; + +function wildcardOverlap({id, property}, objs) { + const idKeys = keys(id).sort(); + const idVals = props(idKeys, id); + for (const obj of objs) { + const {id: id2, property: property2} = obj; + if ( + property2 === property && + typeof id2 !== 'string' && + equals(keys(id2).sort(), idKeys) && + all(matchWildKeys, zip(idVals, props(idKeys, id2))) + ) { + return obj; + } + } + return false; +} + +export function validateCallbacksToLayout(state_, dispatchError) { + const {config, graphs, layout, paths} = state_; + const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs; + const validateIds = !config.suppress_callback_exceptions; + + function tail(callbacks) { + return ( + 'This ID was used in the callback(s) for Output(s):\n ' + + callbacks + .map(({outputs}) => outputs.map(combineIdAndProp).join(', ')) + .join('\n ') + ); + } + + function missingId(id, cls, callbacks) { + dispatchError('ID not found in layout', [ + `Attempting to connect a callback ${cls} item to component:`, + ` "${stringifyId(id)}"`, + '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`.', + tail(callbacks), + ]); + } + + function validateProp(id, idPath, prop, cls, callbacks) { + const component = path(idPath, layout); + const element = Registry.resolve(component); + + // note: Flow components do not have propTypes, so we can't validate. + if (element && element.propTypes && !element.propTypes[prop]) { + // look for wildcard props (ie data-* etc) + for (const propName in element.propTypes) { + const last = propName.length - 1; + if ( + propName.charAt(last) === '*' && + prop.substr(0, last) === propName.substr(0, last) + ) { + return; + } + } + const {type, namespace} = component; + dispatchError('Invalid prop for this component', [ + `Property "${prop}" was used with component ID:`, + ` ${JSON.stringify(id)}`, + `in one of the ${cls} items of a callback.`, + `This ID is assigned to a ${namespace}.${type} component`, + 'in the layout, which does not support this property.', + tail(callbacks), + ]); + } + } + + function validateIdPatternProp(id, property, cls, callbacks) { + resolveDeps()(paths)({id, property}).forEach(dep => { + const {id: idResolved, path: idPath} = dep; + validateProp(idResolved, idPath, property, cls, callbacks); + }); + } + + const callbackIdsCheckedForState = {}; + + function validateState(callback) { + const {state, output} = callback; + + // ensure we don't check the same callback for state multiple times + if (callbackIdsCheckedForState[output]) { + return; + } + callbackIdsCheckedForState[output] = 1; + + const cls = 'State'; + + state.forEach(({id, property}) => { + if (typeof id === 'string') { + const idPath = getPath(paths, id); + if (!idPath) { + if (validateIds) { + missingId(id, cls, [callback]); + } + } else { + validateProp(id, idPath, property, cls, [callback]); + } + } + // Only validate props for State object ids that we don't need to + // resolve them to specific inputs or outputs + else if (!intersection([MATCH, ALLSMALLER], values(id)).length) { + validateIdPatternProp(id, property, cls, [callback]); + } + }); + } + + function validateMap(map, cls, doState) { + for (const id in map) { + const idProps = map[id]; + const idPath = getPath(paths, id); + if (!idPath) { + if (validateIds) { + missingId(id, cls, flatten(values(idProps))); + } + } else { + for (const property in idProps) { + const callbacks = idProps[property]; + validateProp(id, idPath, property, cls, callbacks); + if (doState) { + // It would be redundant to check state on both inputs + // and outputs - so only set doState for outputs. + callbacks.forEach(validateState); + } + } + } + } + } + + validateMap(outputMap, 'Output', true); + validateMap(inputMap, 'Input'); + + function validatePatterns(patterns, cls, doState) { + for (const keyStr in patterns) { + const keyPatterns = patterns[keyStr]; + for (const property in keyPatterns) { + keyPatterns[property].forEach(({keys, values, callbacks}) => { + const id = zipObj(keys, values); + validateIdPatternProp(id, property, cls, callbacks); + if (doState) { + callbacks.forEach(validateState); + } + }); + } + } + } + + validatePatterns(outputPatterns, 'Output', true); + validatePatterns(inputPatterns, 'Input'); +} + +export function computeGraphs(dependencies, dispatchError) { + // multiGraph is just for finding circular deps + const multiGraph = new DepGraph(); + + const wildcardPlaceholders = {}; + + const fixIds = map(evolve({id: parseIfWildcard})); + const parsedDependencies = map(dep => { + const {output} = dep; + const out = evolve({inputs: fixIds, state: fixIds}, dep); + out.outputs = map( + outi => assoc('out', true, splitIdAndProp(outi)), + isMultiOutputProp(output) ? parseMultipleOutputs(output) : [output] + ); + return out; + }, dependencies); + + let hasError = false; + const wrappedDE = (message, lines) => { + hasError = true; + dispatchError(message, lines); + }; + validateDependencies(parsedDependencies, wrappedDE); + + /* + * For regular ids, outputMap and inputMap are: + * {[id]: {[prop]: [callback, ...]}} + * where callbacks are the matching specs from the original + * dependenciesRequest, but with outputs parsed to look like inputs, + * and a list anyKeys added if the outputs have MATCH wildcards. + * For outputMap there should only ever be one callback per id/prop + * but for inputMap there may be many. + * + * For wildcard ids, outputPatterns and inputPatterns are: + * { + * [keystr]: { + * [prop]: [ + * {keys: [...], values: [...], callbacks: [callback, ...]}, + * {...} + * ] + * } + * } + * keystr is a stringified ordered list of keys in the id + * keys is the same ordered list (just copied for convenience) + * values is an array of explicit or wildcard values for each key in keys + */ + const outputMap = {}; + const inputMap = {}; + const outputPatterns = {}; + const inputPatterns = {}; + + const finalGraphs = { + MultiGraph: multiGraph, + outputMap, + inputMap, + outputPatterns, + inputPatterns, + callbacks: parsedDependencies, + }; + + if (hasError) { + // leave the graphs empty if we found an error, so we don't try to + // execute the broken callbacks. + return finalGraphs; + } + + parsedDependencies.forEach(dependency => { + const {outputs, inputs} = dependency; + + outputs.concat(inputs).forEach(item => { + const {id} = item; + if (typeof id === 'object') { + forEachObjIndexed((val, key) => { + if (!wildcardPlaceholders[key]) { + wildcardPlaceholders[key] = { + exact: [], + expand: 0, + }; + } + const keyPlaceholders = wildcardPlaceholders[key]; + if (val && val.wild) { + if (val.expand) { + keyPlaceholders.expand += 1; + } + } else if (keyPlaceholders.exact.indexOf(val) === -1) { + keyPlaceholders.exact.push(val); + } + }, id); + } + }); + }); + + forEachObjIndexed(keyPlaceholders => { + const {exact, expand} = keyPlaceholders; + const vals = exact.slice().sort(idValSort); + if (expand) { + for (let i = 0; i < expand; i++) { + if (exact.length) { + vals.splice(0, 0, [valBefore(vals[0])]); + vals.push(valAfter(vals[vals.length - 1])); + } else { + vals.push(i); + } + } + } else if (!exact.length) { + // only MATCH/ALL - still need a value + vals.push(0); + } + keyPlaceholders.vals = vals; + }, wildcardPlaceholders); + + function makeAllIds(idSpec, outIdFinal) { + let idList = [{}]; + forEachObjIndexed((val, key) => { + const testVals = wildcardPlaceholders[key].vals; + const outValIndex = testVals.indexOf(outIdFinal[key]); + let newVals = [val]; + if (val && val.wild) { + if (val === ALLSMALLER) { + if (outValIndex > 0) { + newVals = testVals.slice(0, outValIndex); + } else { + // no smaller items - delete all outputs. + newVals = []; + } + } else { + // MATCH or ALL + // MATCH *is* ALL for outputs, ie we don't already have a + // value specified in `outIdFinal` + newVals = + outValIndex === -1 || val === ALL + ? testVals + : [outIdFinal[key]]; + } + } + // replicates everything in idList once for each item in + // newVals, attaching each value at key. + idList = ap(ap([assoc(key)], newVals), idList); + }, idSpec); + return idList; + } + + parsedDependencies.forEach(function registerDependency(dependency) { + const {outputs, inputs} = dependency; + + // multiGraph - just for testing circularity + + function addInputToMulti(inIdProp, outIdProp) { + multiGraph.addNode(inIdProp); + multiGraph.addDependency(inIdProp, outIdProp); + } + + function addOutputToMulti(outIdFinal, outIdProp) { + multiGraph.addNode(outIdProp); + inputs.forEach(inObj => { + const {id: inId, property} = inObj; + if (typeof inId === 'object') { + const inIdList = makeAllIds(inId, outIdFinal); + inIdList.forEach(id => { + addInputToMulti( + combineIdAndProp({id, property}), + outIdProp + ); + }); + } else { + addInputToMulti(combineIdAndProp(inObj), outIdProp); + } + }); + } + + // We'll continue to use dep.output as its id, but add outputs as well + // for convenience and symmetry with the structure of inputs and state. + // Also collect MATCH keys in the output (all outputs must share these) + // and ALL keys in the first output (need not be shared but we'll use + // the first output for calculations) for later convenience. + const {anyKeys, hasALL} = findWildcardKeys(outputs[0].id); + const finalDependency = mergeRight( + {hasALL, anyKeys, outputs}, + dependency + ); + + outputs.forEach(outIdProp => { + const {id: outId, property} = outIdProp; + if (typeof outId === 'object') { + const outIdList = makeAllIds(outId, {}); + outIdList.forEach(id => { + addOutputToMulti(id, combineIdAndProp({id, property})); + }); + + addPattern(outputPatterns, outId, property, finalDependency); + } else { + addOutputToMulti({}, combineIdAndProp(outIdProp)); + addMap(outputMap, outId, property, finalDependency); + } + }); + + inputs.forEach(inputObject => { + const {id: inId, property: inProp} = inputObject; + if (typeof inId === 'object') { + addPattern(inputPatterns, inId, inProp, finalDependency); + } else { + addMap(inputMap, inId, inProp, finalDependency); + } + }); + }); + + return finalGraphs; +} + +function findWildcardKeys(id) { + const anyKeys = []; + const allsmallerKeys = []; + let hasALL = false; + if (typeof id === 'object') { + forEachObjIndexed((val, key) => { + if (val === MATCH) { + anyKeys.push(key); + } else if (val === ALLSMALLER) { + allsmallerKeys.push(key); + } else if (val === ALL) { + hasALL = true; + } + }, id); + anyKeys.sort(); + allsmallerKeys.sort(); + } + return {anyKeys, allsmallerKeys, hasALL}; +} + +/* + * Do the given id values `vals` match the pattern `patternVals`? + * `keys`, `patternVals`, and `vals` are all arrays, and we already know that + * we're only looking at ids with the same keys as the pattern. + * + * Optionally, include another reference set of the same - to ensure the + * correct matching of MATCH or ALLSMALLER between input and output items. + */ +function idMatch(keys, vals, patternVals, refKeys, refVals, refPatternVals) { + for (let i = 0; i < keys.length; i++) { + const val = vals[i]; + const patternVal = patternVals[i]; + if (patternVal.wild) { + // If we have a second id, compare the wildcard values. + // Without a second id, all wildcards pass at this stage. + if (refKeys && patternVal !== ALL) { + const refIndex = refKeys.indexOf(keys[i]); + const refPatternVal = refPatternVals[refIndex]; + // Sanity check. Shouldn't ever fail this, if the back end + // did its job validating callbacks. + // You can't resolve an input against an input, because + // two ALLSMALLER's wouldn't make sense! + if (patternVal === ALLSMALLER && refPatternVal === ALLSMALLER) { + throw new Error( + 'invalid wildcard id pair: ' + + JSON.stringify({ + keys, + patternVals, + vals, + refKeys, + refPatternVals, + refVals, + }) + ); + } + if ( + idValSort(val, refVals[refIndex]) !== + (patternVal === ALLSMALLER + ? -1 + : refPatternVal === ALLSMALLER + ? 1 + : 0) + ) { + return false; + } + } + } else if (val !== patternVal) { + return false; + } + } + return true; +} + +function getAnyVals(patternVals, vals) { + const matches = []; + for (let i = 0; i < patternVals.length; i++) { + if (patternVals[i] === MATCH) { + matches.push(vals[i]); + } + } + return matches.length ? JSON.stringify(matches) : ''; +} + +function resolveDeps(refKeys, refVals, refPatternVals) { + return paths => ({id: idPattern, property}) => { + if (typeof idPattern === 'string') { + const path = getPath(paths, idPattern); + return path ? [{id: idPattern, property, path}] : []; + } + const keys = Object.keys(idPattern).sort(); + const patternVals = props(keys, idPattern); + const keyStr = keys.join(','); + const keyPaths = paths.objs[keyStr]; + if (!keyPaths) { + return []; + } + const result = []; + keyPaths.forEach(({values: vals, path}) => { + if ( + idMatch( + keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals + ) + ) { + result.push({id: zipObj(keys, vals), property, path}); + } + }); + return result; + }; +} + +/* + * Create a pending callback object. Includes the original callback definition, + * its resolved ID (including the value of all MATCH wildcards), + * accessors to find all inputs, outputs, and state involved in this + * callback (lazy as not all users will want all of these), + * placeholders for which other callbacks this one is blockedBy or blocking, + * and a boolean for whether it has been dispatched yet. + */ +const makeResolvedCallback = (callback, resolve, anyVals) => ({ + callback, + anyVals, + resolvedId: callback.output + anyVals, + getOutputs: paths => callback.outputs.map(resolve(paths)), + getInputs: paths => callback.inputs.map(resolve(paths)), + getState: paths => callback.state.map(resolve(paths)), + blockedBy: {}, + blocking: {}, + changedPropIds: {}, + initialCall: false, + requestId: 0, + requestedOutputs: {}, +}); + +const DIRECT = 2; +const INDIRECT = 1; + +let nextRequestId = 0; + +/* + * Give a callback a new requestId. + */ +export function setNewRequestId(callback) { + nextRequestId++; + return assoc('requestId', nextRequestId, callback); +} + +/* + * Does this item (input / output / state) support multiple values? + * string IDs do not; wildcard IDs only do if they contain ALL or ALLSMALLER + */ +export function isMultiValued({id}) { + return typeof id === 'object' && any(v => v.multi, values(id)); +} + +/* + * For a given output id and prop, find the callback generating it. + * If no callback is found, returns false. + * If one is found, returns: + * { + * callback: the callback spec {outputs, inputs, state etc} + * anyVals: stringified list of resolved MATCH keys we matched + * resolvedId: the "outputs" id string plus MATCH values we matched + * getOutputs: accessor function to give all resolved outputs of this + * callback. Takes `paths` as argument to apply when the callback is + * dispatched, in case a previous callback has altered the layout. + * The result is a list of {id (string or object), property (string)} + * getInputs: same for inputs + * getState: same for state + * blockedBy: an object of {[resolvedId]: 1} blocking this callback + * blocking: an object of {[resolvedId]: 1} this callback is blocking + * changedPropIds: an object of {[idAndProp]: v} triggering this callback + * v = DIRECT (2): the prop was changed in the front end, so dependent + * callbacks *MUST* be executed. + * v = INDIRECT (1): the prop is expected to be changed by a callback, + * but if this is prevented, dependent callbacks may be pruned. + * initialCall: boolean, if true we don't require any changedPropIds + * to keep this callback around, as it's the initial call to populate + * this value on page load or changing part of the layout. + * By default this is true for callbacks generated by + * getCallbackByOutput, false from getCallbacksByInput. + * requestId: integer: starts at 0. when this callback is dispatched it will + * get a unique requestId, but if it gets added again the requestId will + * be reset to 0, and we'll know to ignore the response of the first + * request. + * requestedOutputs: object of {[idStr]: [props]} listing all the props + * actually requested for update. + * } + */ +function getCallbackByOutput(graphs, paths, id, prop) { + let resolve; + let callback; + let anyVals = ''; + if (typeof id === 'string') { + // standard id version + const callbacks = (graphs.outputMap[id] || {})[prop]; + if (callbacks) { + callback = callbacks[0]; + resolve = resolveDeps(); + } + } else { + // wildcard version + const keys = Object.keys(id).sort(); + const vals = props(keys, id); + const keyStr = keys.join(','); + const patterns = (graphs.outputPatterns[keyStr] || {})[prop]; + if (patterns) { + for (let i = 0; i < patterns.length; i++) { + const patternVals = patterns[i].values; + if (idMatch(keys, vals, patternVals)) { + callback = patterns[i].callbacks[0]; + resolve = resolveDeps(keys, vals, patternVals); + anyVals = getAnyVals(patternVals, vals); + break; + } + } + } + } + if (!resolve) { + return false; + } + + return makeResolvedCallback(callback, resolve, anyVals); +} + +/* + * If there are ALL keys we need to reduce a set of outputs resolved + * from an input to one item per combination of MATCH values. + * That will give one result per callback invocation. + */ +function reduceALLOuts(outs, anyKeys, hasALL) { + if (!hasALL) { + return outs; + } + if (!anyKeys.length) { + // If there's ALL but no MATCH, there's only one invocation + // of the callback, so just base it off the first output. + return [outs[0]]; + } + const anySeen = {}; + return outs.filter(i => { + const matchKeys = JSON.stringify(props(anyKeys, i.id)); + if (!anySeen[matchKeys]) { + anySeen[matchKeys] = 1; + return true; + } + return false; + }); +} + +function addResolvedFromOutputs(callback, outPattern, outs, matches) { + const out0Keys = Object.keys(outPattern.id).sort(); + const out0PatternVals = props(out0Keys, outPattern.id); + outs.forEach(({id: outId}) => { + const outVals = props(out0Keys, outId); + matches.push( + makeResolvedCallback( + callback, + resolveDeps(out0Keys, outVals, out0PatternVals), + getAnyVals(out0PatternVals, outVals) + ) + ); + }); +} + +/* + * For a given id and prop find all callbacks it's an input of. + * + * Returns an array of objects: + * {callback, resolvedId, getOutputs, getInputs, getState} + * See getCallbackByOutput for details. + * + * Note that if the original input contains an ALLSMALLER wildcard, + * there may be many entries for the same callback, but any given output + * (with an MATCH corresponding to the input's ALLSMALLER) will only appear + * in one entry. + */ +export function getCallbacksByInput(graphs, paths, id, prop, changeType) { + const matches = []; + const idAndProp = combineIdAndProp({id, property: prop}); + + if (typeof id === 'string') { + // standard id version + const callbacks = (graphs.inputMap[id] || {})[prop]; + if (!callbacks) { + return []; + } + + const baseResolve = resolveDeps(); + callbacks.forEach(callback => { + const {anyKeys, hasALL} = callback; + if (anyKeys) { + const out0Pattern = callback.outputs[0]; + const out0Set = reduceALLOuts( + baseResolve(paths)(out0Pattern), + anyKeys, + hasALL + ); + addResolvedFromOutputs(callback, out0Pattern, out0Set, matches); + } else { + matches.push(makeResolvedCallback(callback, baseResolve, '')); + } + }); + } else { + // wildcard version + const keys = Object.keys(id).sort(); + const vals = props(keys, id); + const keyStr = keys.join(','); + const patterns = (graphs.inputPatterns[keyStr] || {})[prop]; + if (!patterns) { + return []; + } + patterns.forEach(pattern => { + if (idMatch(keys, vals, pattern.values)) { + const resolve = resolveDeps(keys, vals, pattern.values); + pattern.callbacks.forEach(callback => { + const out0Pattern = callback.outputs[0]; + const {anyKeys, hasALL} = callback; + const out0Set = reduceALLOuts( + resolve(paths)(out0Pattern), + anyKeys, + hasALL + ); + + addResolvedFromOutputs( + callback, + out0Pattern, + out0Set, + matches + ); + }); + } + }); + } + matches.forEach(match => { + match.changedPropIds[idAndProp] = changeType || DIRECT; + }); + return matches; +} + +export function getWatchedKeys(id, newProps, graphs) { + if (!(id && graphs && newProps.length)) { + return []; + } + + if (typeof id === 'string') { + const inputs = graphs.inputMap[id]; + return inputs ? newProps.filter(newProp => inputs[newProp]) : []; + } + + const keys = Object.keys(id).sort(); + const vals = props(keys, id); + const keyStr = keys.join(','); + const keyPatterns = graphs.inputPatterns[keyStr]; + if (!keyPatterns) { + return []; + } + return newProps.filter(prop => { + const patterns = keyPatterns[prop]; + return ( + patterns && + patterns.some(pattern => idMatch(keys, vals, pattern.values)) + ); + }); +} + +/* + * Return a list of all callbacks referencing a chunk of the layout, + * either as inputs or outputs. + * + * opts.outputsOnly: boolean, set true when crawling the *whole* layout, + * because outputs are enough to get everything. + * opts.removedArrayInputsOnly: boolean, set true to only look for inputs in + * wildcard arrays (ALL or ALLSMALLER), no outputs. This gets used to tell + * when the new *absence* of a given component should trigger a callback. + * opts.newPaths: paths object after the edit - to be used with + * removedArrayInputsOnly to determine if the callback still has its outputs + * opts.chunkPath: path to the new chunk - used to determine if any outputs are + * outside of this chunk, because this determines whether inputs inside the + * chunk count as having changed + * + * Returns an array of objects: + * {callback, resolvedId, getOutputs, getInputs, getState, ...etc} + * See getCallbackByOutput for details. + */ +export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) { + const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts; + const foundCbIds = {}; + const callbacks = []; + + function addCallback(callback) { + if (callback) { + const foundIndex = foundCbIds[callback.resolvedId]; + if (foundIndex !== undefined) { + callbacks[foundIndex].changedPropIds = mergeMax( + callbacks[foundIndex].changedPropIds, + callback.changedPropIds + ); + } else { + foundCbIds[callback.resolvedId] = callbacks.length; + callbacks.push(callback); + } + } + } + + function addCallbackIfArray(idStr) { + return cb => + cb.getInputs(paths).some(ini => { + if ( + Array.isArray(ini) && + ini.some(inij => stringifyId(inij.id) === idStr) + ) { + // This callback should trigger even with no changedProps, + // since the props that changed no longer exist. + if (flatten(cb.getOutputs(newPaths)).length) { + cb.initialCall = true; + cb.changedPropIds = {}; + addCallback(cb); + } + return true; + } + return false; + }); + } + + function handleOneId(id, outIdCallbacks, inIdCallbacks) { + if (outIdCallbacks) { + for (const property in outIdCallbacks) { + const cb = getCallbackByOutput(graphs, paths, id, property); + if (cb) { + // callbacks found in the layout by output should always run + // ie this is the initial call of this callback even if it's + // not the page initialization but just a new layout chunk + cb.initialCall = true; + addCallback(cb); + } + } + } + if (!outputsOnly && inIdCallbacks) { + const maybeAddCallback = removedArrayInputsOnly + ? addCallbackIfArray(stringifyId(id)) + : addCallback; + let handleThisCallback = maybeAddCallback; + if (chunkPath) { + handleThisCallback = cb => { + if ( + all( + startsWith(chunkPath), + pluck('path', flatten(cb.getOutputs(paths))) + ) + ) { + cb.changedPropIds = {}; + } + maybeAddCallback(cb); + }; + } + for (const property in inIdCallbacks) { + getCallbacksByInput( + graphs, + paths, + id, + property, + INDIRECT + ).forEach(handleThisCallback); + } + } + } + + crawlLayout(layoutChunk, child => { + const id = path(['props', 'id'], child); + if (id) { + if (typeof id === 'string' && !removedArrayInputsOnly) { + handleOneId(id, graphs.outputMap[id], graphs.inputMap[id]); + } else { + const keyStr = Object.keys(id) + .sort() + .join(','); + handleOneId( + id, + !removedArrayInputsOnly && graphs.outputPatterns[keyStr], + graphs.inputPatterns[keyStr] + ); + } + } + }); + + // We still need to follow these forward in order to capture blocks and, + // if based on a partial layout, any knock-on effects in the full layout. + const finalCallbacks = followForward(graphs, paths, callbacks); + + // Exception to the `initialCall` case of callbacks found by output: + // if *every* input to this callback is itself an output of another + // callback earlier in the chain, we remove the `initialCall` flag + // so that if all of those prior callbacks abort all of their outputs, + // this later callback never runs. + // See test inin003 "callback2 is never triggered, even on initial load" + finalCallbacks.forEach(cb => { + if (cb.initialCall && !isEmpty(cb.blockedBy)) { + const inputs = flatten(cb.getInputs(paths)); + cb.initialCall = false; + inputs.forEach(i => { + const propId = combineIdAndProp(i); + if (cb.changedPropIds[propId]) { + cb.changedPropIds[propId] = INDIRECT; + } else { + cb.initialCall = true; + } + }); + } + }); + + return finalCallbacks; +} + +export function removePendingCallback( + pendingCallbacks, + paths, + removeResolvedId, + skippedProps +) { + const finalPendingCallbacks = []; + pendingCallbacks.forEach(pending => { + const {blockedBy, blocking, changedPropIds, resolvedId} = pending; + if (resolvedId !== removeResolvedId) { + finalPendingCallbacks.push( + mergeRight(pending, { + blockedBy: dissoc(removeResolvedId, blockedBy), + blocking: dissoc(removeResolvedId, blocking), + changedPropIds: pickBy( + (v, k) => v === DIRECT || !includes(k, skippedProps), + changedPropIds + ), + }) + ); + } + }); + // If any callback no longer has any changed inputs, it shouldn't fire. + // This will repeat recursively until all unneeded callbacks are pruned + if (skippedProps.length) { + for (let i = 0; i < finalPendingCallbacks.length; i++) { + const cb = finalPendingCallbacks[i]; + if (!cb.initialCall && isEmpty(cb.changedPropIds)) { + return removePendingCallback( + finalPendingCallbacks, + paths, + cb.resolvedId, + flatten(cb.getOutputs(paths)).map(combineIdAndProp) + ); + } + } + } + return finalPendingCallbacks; +} + +/* + * Split the list of pending callbacks into ready (not blocked by any others) + * and blocked. Sort the ready callbacks by how many each is blocking, on the + * theory that the most important ones to dispatch are the ones with the most + * others depending on them. + */ +export function findReadyCallbacks(pendingCallbacks) { + const [readyCallbacks, blockedCallbacks] = partition( + pending => isEmpty(pending.blockedBy) && !pending.requestId, + pendingCallbacks + ); + readyCallbacks.sort((a, b) => { + return Object.keys(b.blocking).length - Object.keys(a.blocking).length; + }); + + return {readyCallbacks, blockedCallbacks}; +} + +function addBlock(callbacks, blockingId, blockedId) { + callbacks.forEach(({blockedBy, blocking, resolvedId}) => { + if (resolvedId === blockingId || blocking[blockingId]) { + blocking[blockedId] = 1; + } else if (resolvedId === blockedId || blockedBy[blockedId]) { + blockedBy[blockingId] = 1; + } + }); +} + +function collectIds(callbacks) { + const allResolvedIds = {}; + callbacks.forEach(({resolvedId}, i) => { + allResolvedIds[resolvedId] = i; + }); + return allResolvedIds; +} + +/* + * Take a list of callbacks and follow them all forward, ie see if any of their + * outputs are inputs of another callback. Any new callbacks get added to the + * list. All that come after another get marked as blocked by that one, whether + * they were in the initial list or not. + */ +export function followForward(graphs, paths, callbacks_) { + const callbacks = clone(callbacks_); + const allResolvedIds = collectIds(callbacks); + let i; + let callback; + + const followOutput = ({id, property}) => { + const nextCBs = getCallbacksByInput( + graphs, + paths, + id, + property, + INDIRECT + ); + nextCBs.forEach(nextCB => { + let existingIndex = allResolvedIds[nextCB.resolvedId]; + if (existingIndex === undefined) { + existingIndex = callbacks.length; + callbacks.push(nextCB); + allResolvedIds[nextCB.resolvedId] = existingIndex; + } else { + const existingCB = callbacks[existingIndex]; + existingCB.changedPropIds = mergeMax( + existingCB.changedPropIds, + nextCB.changedPropIds + ); + } + addBlock(callbacks, callback.resolvedId, nextCB.resolvedId); + }); + }; + + // Using a for loop instead of forEach because followOutput may extend the + // callbacks array, and we want to continue into these new elements. + for (i = 0; i < callbacks.length; i++) { + callback = callbacks[i]; + const outputs = unnest(callback.getOutputs(paths)); + outputs.forEach(followOutput); + } + return callbacks; +} + +function mergeAllBlockers(cb1, cb2) { + function mergeBlockers(a, b) { + if (cb1[a][cb2.resolvedId] && !cb2[b][cb1.resolvedId]) { + cb2[b][cb1.resolvedId] = cb1[a][cb2.resolvedId]; + cb2[b] = mergeMax(cb1[b], cb2[b]); + cb1[a] = mergeMax(cb2[a], cb1[a]); + } + } + mergeBlockers('blockedBy', 'blocking'); + mergeBlockers('blocking', 'blockedBy'); +} + +/* + * Given two arrays of pending callbacks, merge them into one so that + * each will only fire once, and any extra blockages from combining the lists + * will be accounted for. + */ +export function mergePendingCallbacks(cb1, cb2) { + if (!cb2.length) { + return cb1; + } + if (!cb1.length) { + return cb2; + } + const finalCallbacks = clone(cb1); + const callbacks2 = clone(cb2); + const allResolvedIds = collectIds(finalCallbacks); + + callbacks2.forEach((callback, i) => { + const existingIndex = allResolvedIds[callback.resolvedId]; + if (existingIndex !== undefined) { + finalCallbacks.forEach(finalCb => { + mergeAllBlockers(finalCb, callback); + }); + callbacks2.slice(i + 1).forEach(cb2 => { + mergeAllBlockers(cb2, callback); + }); + finalCallbacks[existingIndex] = mergeDeepRight( + finalCallbacks[existingIndex], + callback + ); + } else { + allResolvedIds[callback.resolvedId] = finalCallbacks.length; + finalCallbacks.push(callback); + } + }); + + return finalCallbacks; +} + +/* + * Remove callbacks whose outputs or changed inputs have been removed + * from the layout + */ +export function pruneRemovedCallbacks(pendingCallbacks, paths) { + const removeIds = []; + let cleanedCallbacks = pendingCallbacks.map(callback => { + const {changedPropIds, getOutputs, resolvedId} = callback; + if (!flatten(getOutputs(paths)).length) { + removeIds.push(resolvedId); + return callback; + } + + let omittedProps = false; + const newChangedProps = pickBy((_, propId) => { + if (getPath(paths, splitIdAndProp(propId).id)) { + return true; + } + omittedProps = true; + return false; + }, changedPropIds); + + return omittedProps + ? assoc('changedPropIds', newChangedProps, callback) + : callback; + }); + + removeIds.forEach(resolvedId => { + const cb = cleanedCallbacks.find(propEq('resolvedId', resolvedId)); + if (cb) { + cleanedCallbacks = removePendingCallback( + pendingCallbacks, + paths, + resolvedId, + flatten(cb.getOutputs(paths)).map(combineIdAndProp) + ); + } + }); + + return cleanedCallbacks; +} diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index fae92ce060..999ade8569 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -1,54 +1,71 @@ import { - adjust, - any, - append, concat, - findIndex, - findLastIndex, flatten, - flip, has, - includes, - intersection, isEmpty, keys, - lensPath, - mergeLeft, + map, mergeDeepRight, once, path, + pick, + pickBy, pluck, propEq, - reject, - slice, - sort, type, uniq, - view, + without, + zip, } from 'ramda'; import {createAction} from 'redux-actions'; -import {crawlLayout, hasId} from '../reducers/utils'; import {getAppState} from '../reducers/constants'; import {getAction} from './constants'; import cookie from 'cookie'; -import {uid, urlBase, isMultiOutputProp, parseMultipleOutputs} from '../utils'; +import {urlBase} from './utils'; +import { + combineIdAndProp, + findReadyCallbacks, + followForward, + getCallbacksByInput, + getCallbacksInLayout, + isMultiOutputProp, + isMultiValued, + mergePendingCallbacks, + removePendingCallback, + parseIfWildcard, + pruneRemovedCallbacks, + setNewRequestId, + stringifyId, + validateCallbacksToLayout, +} from './dependencies'; +import {computePaths, getPath} from './paths'; import {STATUS} from '../constants/constants'; import {applyPersistence, prunePersistence} from '../persistence'; import isAppReady from './isAppReady'; export const updateProps = createAction(getAction('ON_PROP_CHANGE')); +export const setPendingCallbacks = createAction('SET_PENDING_CALLBACKS'); export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); -export const computeGraphs = createAction(getAction('COMPUTE_GRAPHS')); -export const computePaths = createAction(getAction('COMPUTE_PATHS')); +export const setGraphs = createAction(getAction('SET_GRAPHS')); +export const setPaths = createAction(getAction('SET_PATHS')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); export const setConfig = createAction(getAction('SET_CONFIG')); export const setHooks = createAction(getAction('SET_HOOKS')); export const setLayout = createAction(getAction('SET_LAYOUT')); export const onError = createAction(getAction('ON_ERROR')); +export const dispatchError = dispatch => (message, lines) => + dispatch( + onError({ + type: 'backEnd', + error: {message, html: lines.join('\n')}, + }) + ); + export function hydrateInitialOutputs() { return function(dispatch, getState) { + validateCallbacksToLayout(getState(), dispatchError(dispatch)); triggerDefaultState(dispatch, getState); dispatch(setAppLifecycle(getAppState('HYDRATED'))); }; @@ -69,13 +86,11 @@ export function getCSRFHeader() { } function triggerDefaultState(dispatch, getState) { - const {graphs} = getState(); - const {InputGraph, MultiGraph} = graphs; - const allNodes = InputGraph.overallOrder(); - // overallOrder will assert circular dependencies for multi output. + const {graphs, paths, layout} = getState(); + // overallOrder will assert circular dependencies for multi output. try { - MultiGraph.overallOrder(); + graphs.MultiGraph.overallOrder(); } catch (err) { dispatch( onError({ @@ -88,652 +103,323 @@ function triggerDefaultState(dispatch, getState) { ); } - const inputNodeIds = []; - allNodes.reverse(); - allNodes.forEach(nodeId => { - const componentId = nodeId.split('.')[0]; - /* - * Filter out the outputs, - * inputs that aren't leaves, - * and the invisible inputs - */ - if ( - InputGraph.dependenciesOf(nodeId).length > 0 && - InputGraph.dependantsOf(nodeId).length === 0 && - has(componentId, getState().paths) - ) { - inputNodeIds.push(nodeId); - } - }); - - reduceInputIds(inputNodeIds, InputGraph).forEach(inputOutput => { - const [componentId, componentProp] = inputOutput.input.split('.'); - // Get the initial property - const propLens = lensPath( - concat(getState().paths[componentId], ['props', componentProp]) - ); - const propValue = view(propLens, getState().layout); - - dispatch( - notifyObservers({ - id: componentId, - props: {[componentProp]: propValue}, - excludedOutputs: inputOutput.excludedOutputs, - }) - ); + const initialCallbacks = getCallbacksInLayout(graphs, paths, layout, { + outputsOnly: true, }); + dispatch(startCallbacks(initialCallbacks)); } -export function redo() { - return function(dispatch, getState) { - const history = getState().history; - dispatch(createAction('REDO')()); - const next = history.future[0]; - - // Update props - dispatch( - createAction('REDO_PROP_CHANGE')({ - itempath: getState().paths[next.id], - props: next.props, - }) - ); - - // Notify observers - dispatch( - notifyObservers({ - id: next.id, - props: next.props, - }) - ); - }; -} - -const UNDO = createAction('UNDO')(); -export function undo() { - return undo_revert(UNDO); -} - -const REVERT = createAction('REVERT')(); -export function revert() { - return undo_revert(REVERT); -} +export const redo = moveHistory('REDO'); +export const undo = moveHistory('UNDO'); +export const revert = moveHistory('REVERT'); -function undo_revert(undo_or_revert) { +function moveHistory(changeType) { return function(dispatch, getState) { - const history = getState().history; - dispatch(undo_or_revert); - const previous = history.past[history.past.length - 1]; - - // Update props - dispatch( - createAction('UNDO_PROP_CHANGE')({ - itempath: getState().paths[previous.id], - props: previous.props, - }) - ); - - // Notify observers - dispatch( - notifyObservers({ - id: previous.id, - props: previous.props, - }) - ); - }; -} - -function reduceInputIds(nodeIds, InputGraph) { - /* - * Create input-output(s) pairs, - * sort by number of outputs, - * and remove redundant inputs (inputs that update the same output) - */ - const inputOutputPairs = nodeIds.map(nodeId => ({ - input: nodeId, - // TODO - Does this include grandchildren? - outputs: InputGraph.dependenciesOf(nodeId), - excludedOutputs: [], - })); - - const sortedInputOutputPairs = sort( - (a, b) => b.outputs.length - a.outputs.length, - inputOutputPairs - ); - - /* - * In some cases, we may have unique outputs but inputs that could - * trigger components to update multiple times. - * - * For example, [A, B] => C and [A, D] => E - * The unique inputs might be [A, B, D] but that is redundant. - * We only need to update B and D or just A. - * - * In these cases, we'll supply an additional list of outputs - * to exclude. - */ - sortedInputOutputPairs.forEach((pair, i) => { - const outputsThatWillBeUpdated = flatten( - pluck('outputs', slice(0, i, sortedInputOutputPairs)) - ); - pair.outputs.forEach(output => { - if (includes(output, outputsThatWillBeUpdated)) { - pair.excludedOutputs.push(output); - } - }); - }); - - return sortedInputOutputPairs; -} - -export function notifyObservers(payload) { - return async function(dispatch, getState) { - const {id, props, excludedOutputs} = payload; - - const { - dependenciesRequest, - graphs, - layout, - paths, - requestQueue, - } = getState(); - - const {InputGraph} = graphs; - /* - * Figure out all of the output id's that depend on this input. - * This includes id's that are direct children as well as - * grandchildren. - * grandchildren will get filtered out in a later stage. - */ - let outputObservers = []; - - const changedProps = keys(props); - changedProps.forEach(propName => { - const node = `${id}.${propName}`; - if (!InputGraph.hasNode(node)) { - return; - } - InputGraph.dependenciesOf(node).forEach(outputId => { - /* - * Multiple input properties that update the same - * output can change at once. - * For example, `n_clicks` and `n_clicks_previous` - * on a button component. - * We only need to update the output once for this - * update, so keep outputObservers unique. - */ - if (!includes(outputId, outputObservers)) { - outputObservers.push(outputId); - } - }); - }); - - if (excludedOutputs) { - outputObservers = reject( - flip(includes)(excludedOutputs), - outputObservers + const {history, paths} = getState(); + dispatch(createAction(changeType)()); + const {id, props} = + (changeType === 'REDO' + ? history.future[0] + : history.past[history.past.length - 1]) || {}; + if (id) { + // Update props + dispatch( + createAction('UNDO_PROP_CHANGE')({ + itempath: getPath(paths, id), + props, + }) ); - } - if (isEmpty(outputObservers)) { - return; + dispatch(notifyObservers({id, props})); } + }; +} - /* - * There may be several components that depend on this input. - * And some components may depend on other components before - * updating. Get this update order straightened out. - */ - const depOrder = InputGraph.overallOrder(); - outputObservers = sort( - (a, b) => depOrder.indexOf(b) - depOrder.indexOf(a), - outputObservers - ); - const queuedObservers = []; - outputObservers.forEach(function filterObservers(outputIdAndProp) { - let outputIds; - if (isMultiOutputProp(outputIdAndProp)) { - outputIds = parseMultipleOutputs(outputIdAndProp).map( - e => e.split('.')[0] +function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { + if (isMultiValued(spec)) { + return idProps; + } + if (idProps.length !== 1) { + if (!idProps.length) { + if (typeof spec.id === 'string') { + throw new ReferenceError( + 'A nonexistent object was used in an `' + + depType + + '` of a Dash callback. The id of this object is `' + + spec.id + + '` and the property is `' + + spec.property + + '`. The string ids in the current layout are: [' + + keys(paths.strs).join(', ') + + ']' ); - } else { - outputIds = [outputIdAndProp.split('.')[0]]; - } - - /* - * before we make the POST to update the output, check - * that the output doesn't depend on any other inputs that - * that depend on the same controller. - * if the output has another input with a shared controller, - * then don't update this output yet. - * when each dependency updates, it'll dispatch its own - * `notifyObservers` action which will allow this - * component to update. - * - * for example, if A updates B and C (A -> [B, C]) and B updates C - * (B -> C), then when A updates, this logic will - * reject C from the queue since it will end up getting updated - * by B. - * - * in this case, B will already be in queuedObservers by the time - * this loop hits C because of the overallOrder sorting logic - */ - - const controllers = InputGraph.dependantsOf(outputIdAndProp); - - const controllersInFutureQueue = intersection( - queuedObservers, - controllers - ); - - /* - * check that the output hasn't been triggered to update already - * by a different input. - * - * for example: - * Grandparent -> [Parent A, Parent B] -> Child - * - * when Grandparent changes, it will trigger Parent A and Parent B - * to each update Child. - * one of the components (Parent A or Parent B) will queue up - * the change for Child. if this update has already been queued up, - * then skip the update for the other component - */ - const controllerIsInExistingQueue = any( - r => - includes(r.controllerId, controllers) && - r.status === 'loading', - requestQueue - ); - - /* - * TODO - Place throttling logic here? - * - * Only process the last two requests for a _single_ output - * at a time. - * - * For example, if A -> B, and A is changed 10 times, then: - * 1 - processing the first two requests - * 2 - if more than 2 requests come in while the first two - * are being processed, then skip updating all of the - * requests except for the last 2 - */ - - /* - * also check that this observer is actually in the current - * component tree. - * observers don't actually need to be rendered at the moment - * of a controller change. - * for example, perhaps the user has hidden one of the observers - */ - - if ( - controllersInFutureQueue.length === 0 && - any(e => has(e, getState().paths))(outputIds) && - !controllerIsInExistingQueue - ) { - queuedObservers.push(outputIdAndProp); } - }); - - /** - * Determine the id of all components used as input or state in the callbacks - * triggered by the props change. - * - * Wait for all components associated to these ids to be ready before initiating - * the callbacks. - */ - const deps = queuedObservers.map(output => - dependenciesRequest.content.find( - dependency => dependency.output === output - ) - ); - - const ids = uniq( - flatten( - deps.map(dep => [ - dep.inputs.map(input => input.id), - dep.state.map(state => state.id), - ]) - ) - ); - - await isAppReady(layout, paths, ids); - - /* - * record the set of output IDs that will eventually need to be - * updated in a queue. not all of these requests will be fired in this - * action - */ - const newRequestQueue = queuedObservers.map(i => ({ - controllerId: i, - status: 'loading', - uid: uid(), - requestTime: Date.now(), - })); - dispatch(setRequestQueue(concat(requestQueue, newRequestQueue))); - - const promises = []; - for (let i = 0; i < queuedObservers.length; i++) { - const outputIdAndProp = queuedObservers[i]; - const requestUid = newRequestQueue[i].uid; - - promises.push( - updateOutput( - outputIdAndProp, - getState, - requestUid, - dispatch, - changedProps.map(prop => `${id}.${prop}`) - ) + // TODO: unwrapped list of wildcard ids? + // eslint-disable-next-line no-console + console.log(paths.objs); + throw new ReferenceError( + 'A nonexistent object was used in an `' + + depType + + '` of a Dash callback. The id of this object is ' + + JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '') + + ' and the property is `' + + spec.property + + '`. The wildcard ids currently available are logged above.' ); } - - /* eslint-disable-next-line consistent-return */ - return Promise.all(promises); - }; + throw new ReferenceError( + 'Multiple objects were found for an `' + + depType + + '` of a callback that only takes one value. The id spec is ' + + JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '') + + ' and the property is `' + + spec.property + + '`. The objects we found are: ' + + JSON.stringify(map(pick(['id', 'property']), idProps)) + ); + } + return idProps[0]; } -function updateOutput( - outputIdAndProp, - getState, - requestUid, - dispatch, - changedPropIds -) { - const {config, layout, graphs, dependenciesRequest, hooks} = getState(); - const {InputGraph} = graphs; - - const getThisRequestIndex = () => { - const postRequestQueue = getState().requestQueue; - const thisRequestIndex = findIndex( - propEq('uid', requestUid), - postRequestQueue - ); - return thisRequestIndex; +function startCallbacks(callbacks) { + return async function(dispatch, getState) { + return await fireReadyCallbacks(dispatch, getState, callbacks); }; +} - const updateRequestQueue = (rejected, status) => { - const postRequestQueue = getState().requestQueue; - const thisRequestIndex = getThisRequestIndex(); - if (thisRequestIndex === -1) { - // It was already pruned away - return; - } - const updatedQueue = adjust( - thisRequestIndex, - mergeLeft({ - status: status, - responseTime: Date.now(), - rejected, - }), - postRequestQueue - ); - // We don't need to store any requests before this one - const thisControllerId = - postRequestQueue[thisRequestIndex].controllerId; - const prunedQueue = updatedQueue.filter((queueItem, index) => { - return ( - queueItem.controllerId !== thisControllerId || - index >= thisRequestIndex - ); +async function fireReadyCallbacks(dispatch, getState, callbacks) { + const {readyCallbacks, blockedCallbacks} = findReadyCallbacks(callbacks); + const {config, hooks, layout, paths} = getState(); + + // We want to calculate all the outputs only once, but we need them + // for pendingCallbacks which we're going to dispatch prior to + // initiating the queue. So first loop over readyCallbacks to + // generate the output lists, then dispatch pendingCallbacks, + // then loop again to fire off the requests. + const outputStash = {}; + const requestedCallbacks = readyCallbacks.map(cb => { + const cbOut = setNewRequestId(cb); + + const {requestId, getOutputs} = cbOut; + const allOutputs = getOutputs(paths); + const flatOutputs = flatten(allOutputs); + const allPropIds = []; + + const reqOut = {}; + flatOutputs.forEach(({id, property}) => { + const idStr = stringifyId(id); + const idOut = (reqOut[idStr] = reqOut[idStr] || []); + idOut.push(property); + allPropIds.push(combineIdAndProp({id: idStr, property})); }); + cbOut.requestedOutputs = reqOut; - dispatch(setRequestQueue(prunedQueue)); - }; - - /* - * Construct a payload of the input and state. - * For example: - * { - * inputs: [{'id': 'input1', 'property': 'new value'}], - * state: [{'id': 'state1', 'property': 'existing value'}] - * } - */ - - // eslint-disable-next-line no-unused-vars - const [outputComponentId, _] = outputIdAndProp.split('.'); - const payload = { - output: outputIdAndProp, - changedPropIds, - }; - - const { - inputs, - state, - clientside_function, - } = dependenciesRequest.content.find( - dependency => dependency.output === outputIdAndProp - ); - const validKeys = keys(getState().paths); + outputStash[requestId] = {allOutputs, allPropIds}; - payload.inputs = inputs.map(inputObject => { - // Make sure the component id exists in the layout - if (!includes(inputObject.id, validKeys)) { - throw new ReferenceError( - 'An invalid input object was used in an ' + - '`Input` of a Dash callback. ' + - 'The id of this object is `' + - inputObject.id + - '` and the property is `' + - inputObject.property + - '`. The list of ids in the current layout is ' + - '`[' + - validKeys.join(', ') + - ']`' - ); - } - const propLens = lensPath( - concat(getState().paths[inputObject.id], [ - 'props', - inputObject.property, - ]) - ); - return { - id: inputObject.id, - property: inputObject.property, - value: view(propLens, layout), - }; + return cbOut; }); - const inputsPropIds = inputs.map(p => `${p.id}.${p.property}`); + const allCallbacks = concat(requestedCallbacks, blockedCallbacks); + dispatch(setPendingCallbacks(allCallbacks)); - payload.changedPropIds = changedPropIds.filter(p => - includes(p, inputsPropIds) - ); + const ids = requestedCallbacks.map(cb => [ + cb.getInputs(paths), + cb.getState(paths), + ]); + await isAppReady(layout, paths, uniq(pluck('id', flatten(ids)))); - if (state.length > 0) { - payload.state = state.map(stateObject => { - // Make sure the component id exists in the layout - if (!includes(stateObject.id, validKeys)) { - throw new ReferenceError( - 'An invalid input object was used in a ' + - '`State` object of a Dash callback. ' + - 'The id of this object is `' + - stateObject.id + - '` and the property is `' + - stateObject.property + - '`. The list of ids in the current layout is ' + - '`[' + - validKeys.join(', ') + - ']`' - ); - } - const propLens = lensPath( - concat(getState().paths[stateObject.id], [ - 'props', - stateObject.property, - ]) - ); - return { - id: stateObject.id, - property: stateObject.property, - value: view(propLens, layout), - }; - }); - } - - function doUpdateProps(id, updatedProps) { - const {layout, paths} = getState(); - const itempath = paths[id]; - if (!itempath) { - return false; - } - - // This is a callback-generated update. - // Check if this invalidates existing persisted prop values, - // or if persistence changed, whether this updates other props. - const updatedProps2 = prunePersistence( - path(itempath, layout), - updatedProps, - dispatch + function fireNext() { + return fireReadyCallbacks( + dispatch, + getState, + getState().pendingCallbacks ); - - // In case the update contains whole components, see if any of - // those components have props to update to persist user edits. - const {props} = applyPersistence({props: updatedProps2}, dispatch); - - dispatch( - updateProps({ - itempath, - props, - source: 'response', - }) - ); - - return props; } - // Clientside hook - if (clientside_function) { - let returnValue; + let hasClientSide = false; - /* - * Create the dash_clientside namespace if it doesn't exist and inject - * no_update and PreventUpdate. - */ - if (!window.dash_clientside) { - window.dash_clientside = {}; - } + const queue = requestedCallbacks.map(cb => { + const {output, inputs, state, clientside_function} = cb.callback; + const {requestId, resolvedId} = cb; + const {allOutputs, allPropIds} = outputStash[requestId]; - if (!window.dash_clientside.no_update) { - Object.defineProperty(window.dash_clientside, 'no_update', { - value: {description: 'Return to prevent updating an Output.'}, - writable: false, - }); + let payload; + try { + const outputs = allOutputs.map((out, i) => + unwrapIfNotMulti( + paths, + map(pick(['id', 'property']), out), + cb.callback.outputs[i], + cb.anyVals, + 'Output' + ) + ); - Object.defineProperty(window.dash_clientside, 'PreventUpdate', { - value: {description: 'Throw to prevent updating all Outputs.'}, - writable: false, - }); + payload = { + output, + outputs: isMultiOutputProp(output) ? outputs : outputs[0], + inputs: fillVals(paths, layout, cb, inputs, 'Input'), + changedPropIds: keys(cb.changedPropIds), + }; + if (cb.callback.state.length) { + payload.state = fillVals(paths, layout, cb, state, 'State'); + } + } catch (e) { + handleError(e); + return fireNext(); } - try { - returnValue = window.dash_clientside[clientside_function.namespace][ - clientside_function.function_name - ]( - ...pluck('value', payload.inputs), - ...(has('state', payload) ? pluck('value', payload.state) : []) + function updatePending(pendingCallbacks, skippedProps) { + const newPending = removePendingCallback( + pendingCallbacks, + getState().paths, + resolvedId, + skippedProps ); - } catch (e) { - /* - * Prevent all updates. - */ - if (e === window.dash_clientside.PreventUpdate) { - updateRequestQueue(true, STATUS.PREVENT_UPDATE); + dispatch(setPendingCallbacks(newPending)); + } + + function handleData(data) { + let {pendingCallbacks} = getState(); + if (!requestIsActive(pendingCallbacks, resolvedId, requestId)) { return; } + const updated = []; + Object.entries(data).forEach(([id, props]) => { + const parsedId = parseIfWildcard(id); - /* eslint-disable-next-line no-console */ - console.error( - `The following error occurred while executing ${clientside_function.namespace}.${clientside_function.function_name} ` + - `in order to update component "${payload.output}" ⋁⋁⋁` - ); - /* eslint-disable-next-line no-console */ - console.error(e); - - /* - * Update the request queue by treating an unsuccessful clientside - * like a failed serverside response via same request queue - * mechanism - */ + const {layout: oldLayout, paths: oldPaths} = getState(); - updateRequestQueue(true, STATUS.CLIENTSIDE_ERROR); - return; - } + const appliedProps = doUpdateProps( + dispatch, + getState, + parsedId, + props + ); + if (appliedProps) { + // doUpdateProps can cause new callbacks to be added + // via derived props - update pendingCallbacks + // But we may also need to merge in other callbacks that + // we found in an earlier interation of the data loop. + const statePendingCallbacks = getState().pendingCallbacks; + if (statePendingCallbacks !== pendingCallbacks) { + pendingCallbacks = mergePendingCallbacks( + pendingCallbacks, + statePendingCallbacks + ); + } - // Returning promises isn't support atm - if (type(returnValue) === 'Promise') { - /* eslint-disable-next-line no-console */ - console.error( - 'The clientside function ' + - `${clientside_function.namespace}.${clientside_function.function_name} ` + - 'returned a Promise instead of a value. Promises are not ' + - 'supported in Dash clientside right now, but may be in the ' + - 'future.' - ); - updateRequestQueue(true, STATUS.CLIENTSIDE_ERROR); - return; - } + Object.keys(appliedProps).forEach(property => { + updated.push(combineIdAndProp({id, property})); + }); - function updateClientsideOutput(outputIdAndProp, outputValue) { - const [outputId, outputProp] = outputIdAndProp.split('.'); - const updatedProps = { - [outputProp]: outputValue, - }; + if (has('children', appliedProps)) { + const oldChildren = path( + concat(getPath(oldPaths, parsedId), [ + 'props', + 'children', + ]), + oldLayout + ); + // If components changed, need to update paths, + // check if all pending callbacks are still + // valid, and add all callbacks associated with + // new components, either as inputs or outputs, + // or components removed from ALL/ALLSMALLER inputs + pendingCallbacks = updateChildPaths( + dispatch, + getState, + pendingCallbacks, + parsedId, + appliedProps.children, + oldChildren + ); + } - /* - * Update the request queue by treating a successful clientside - * like a successful serverside response (200 status code) - */ - updateRequestQueue(false, STATUS.OK); + // persistence edge case: if you explicitly update the + // persistence key, other props may change that require us + // to fire additional callbacks + const addedProps = pickBy( + (v, k) => !(k in props), + appliedProps + ); + if (!isEmpty(addedProps)) { + const {graphs, paths} = getState(); + pendingCallbacks = includeObservers( + id, + addedProps, + graphs, + paths, + pendingCallbacks + ); + } + } + }); + updatePending(pendingCallbacks, without(updated, allPropIds)); + } - /* - * Prevent update. - */ - if (outputValue === window.dash_clientside.no_update) { - return; + function handleError(err) { + const {pendingCallbacks} = getState(); + if (requestIsActive(pendingCallbacks, resolvedId, requestId)) { + // Skip all prop updates from this callback, and remove + // it from the pending list so callbacks it was blocking + // that have other changed inputs will still fire. + updatePending(pendingCallbacks, allPropIds); } - - // Update the layout with the new result - const appliedProps = doUpdateProps(outputId, updatedProps); - - /* - * This output could itself be a serverside or clientside input - * to another function - */ - if (appliedProps) { - dispatch( - notifyObservers({ - id: outputId, - props: appliedProps, - }) - ); + const outputs = payload + ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') + : output; + let message = `Callback error updating ${outputs}`; + if (clientside_function) { + const {namespace: ns, function_name: fn} = clientside_function; + message += ` via clientside function ${ns}.${fn}`; } + handleAsyncError(err, message, dispatch); } - if (isMultiOutputProp(payload.output)) { - parseMultipleOutputs(payload.output).forEach((outputPropId, i) => { - updateClientsideOutput(outputPropId, returnValue[i]); - }); - } else { - updateClientsideOutput(payload.output, returnValue); + if (clientside_function) { + try { + handleData(handleClientside(clientside_function, payload)); + } catch (err) { + handleError(err); + } + hasClientSide = true; + return null; } - /* - * Note that unlike serverside updates, we're not handling - * children as components right now, so we don't need to - * crawl the computed result to check for nested components - * or properties that might trigger other inputs. - * In the future, we could handle this case. - */ - return; - } + return handleServerside(config, payload, hooks) + .then(handleData) + .catch(handleError) + .then(fireNext); + }); + const done = Promise.all(queue); + return hasClientSide ? fireNext().then(done) : done; +} + +function fillVals(paths, layout, cb, specs, depType) { + const getter = depType === 'Input' ? cb.getInputs : cb.getState; + return getter(paths).map((inputList, i) => + unwrapIfNotMulti( + paths, + inputList.map(({id, property, path: path_}) => ({ + id, + property, + value: path(path_, layout).props[property], + })), + specs[i], + cb.anyVals, + depType + ) + ); +} +function handleServerside(config, payload, hooks) { if (hooks.request_pre !== null) { hooks.request_pre(payload); } - /* eslint-disable-next-line consistent-return */ return fetch( `${urlBase(config)}_dash-update-component`, mergeDeepRight(config.fetch, { @@ -741,308 +427,204 @@ function updateOutput( headers: getCSRFHeader(), body: JSON.stringify(payload), }) - ) - .then(function handleResponse(res) { - const isRejected = () => { - const latestRequestIndex = findLastIndex( - propEq('controllerId', outputIdAndProp), - getState().requestQueue - ); - /* - * Note that if the latest request is still `loading` - * or even if the latest request failed, - * we still reject this response in favor of waiting - * for the latest request to finish. - */ - const rejected = latestRequestIndex > getThisRequestIndex(); - return rejected; - }; - - if (res.status !== STATUS.OK) { - // update the status of this request - updateRequestQueue(true, res.status); + ).then(res => { + const {status} = res; + if (status === STATUS.OK) { + return res.json().then(data => { + const {multi, response} = data; + if (hooks.request_post !== null) { + hooks.request_post(payload, response); + } - /* - * This is a 204 response code, there's no content to process. - */ - if (res.status === STATUS.PREVENT_UPDATE) { - return; + if (multi) { + return response; } - /* - * eject into `catch` handler below to display error - * message in ui - */ - throw res; - } + const {output} = payload; + const id = output.substr(0, output.lastIndexOf('.')); + return {[id]: response.props}; + }); + } + if (status === STATUS.PREVENT_UPDATE) { + return {}; + } + throw res; + }); +} - /* - * Check to see if another request has already come back - * _after_ this one. - * If so, ignore this request. - */ - if (isRejected()) { - updateRequestQueue(true, res.status); - return; - } +const getVals = input => + Array.isArray(input) ? pluck('value', input) : input.value; - res.json().then(function handleJson(data) { - /* - * Even if the `res` was received in the correct order, - * the remainder of the response (res.json()) could happen - * at different rates causing the parsed responses to - * get out of order - */ - if (isRejected()) { - updateRequestQueue(true, res.status); - return; - } +const zipIfArray = (a, b) => (Array.isArray(a) ? zip(a, b) : [[a, b]]); - updateRequestQueue(false, res.status); +function handleClientside(clientside_function, payload) { + const dc = (window.dash_clientside = window.dash_clientside || {}); + if (!dc.no_update) { + Object.defineProperty(dc, 'no_update', { + value: {description: 'Return to prevent updating an Output.'}, + writable: false, + }); - // Fire custom request_post hook if any - if (hooks.request_post !== null) { - hooks.request_post(payload, data.response); - } + Object.defineProperty(dc, 'PreventUpdate', { + value: {description: 'Throw to prevent updating all Outputs.'}, + writable: false, + }); + } - /* - * it's possible that this output item is no longer visible. - * for example, the could still be request running when - * the user switched the chapter - * - * if it's not visible, then ignore the rest of the updates - * to the store - */ + const {inputs, outputs, state} = payload; - const multi = data.multi; + let returnValue; - const handleResponse = ([outputIdAndProp, props]) => { - // Backward compatibility - const pathKey = multi ? outputIdAndProp : outputComponentId; + try { + const {namespace, function_name} = clientside_function; + let args = inputs.map(getVals); + if (state) { + args = concat(args, state.map(getVals)); + } + returnValue = dc[namespace][function_name](...args); + } catch (e) { + if (e === dc.PreventUpdate) { + return {}; + } + throw e; + } - const appliedProps = doUpdateProps(pathKey, props); - if (!appliedProps) { - return; - } + if (type(returnValue) === 'Promise') { + throw new Error( + 'The clientside function returned a Promise. ' + + 'Promises are not supported in Dash clientside ' + + 'right now, but may be in the future.' + ); + } - dispatch( - notifyObservers({ - id: pathKey, - props: appliedProps, - }) - ); + const data = {}; + zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { + zipIfArray(outi, reti).forEach(([outij, retij]) => { + const {id, property} = outij; + const idStr = stringifyId(id); + const dataForId = (data[idStr] = data[idStr] || {}); + if (retij !== dc.no_update) { + dataForId[property] = retij; + } + }); + }); + return data; +} - /* - * If the response includes children, then we need to update our - * paths store. - * TODO - Do we need to wait for updateProps to finish? - */ - if (has('children', appliedProps)) { - const newChildren = appliedProps.children; - dispatch( - computePaths({ - subTree: newChildren, - startingPath: concat( - getState().paths[pathKey], - ['props', 'children'] - ), - }) - ); +function requestIsActive(pendingCallbacks, resolvedId, requestId) { + const thisCallback = pendingCallbacks.find( + propEq('resolvedId', resolvedId) + ); + // could be inactivated if it was requested again, in which case it could + // potentially even have finished and been removed from the list + return thisCallback && thisCallback.requestId === requestId; +} - /* - * if children contains objects with IDs, then we - * need to dispatch a propChange for all of these - * new children components - */ - if ( - includes(type(newChildren), ['Array', 'Object']) && - !isEmpty(newChildren) - ) { - /* - * TODO: We're just naively crawling - * the _entire_ layout to recompute the - * the dependency graphs. - * We don't need to do this - just need - * to compute the subtree - */ - const newProps = {}; - crawlLayout(newChildren, function appendIds(child) { - if (hasId(child)) { - keys(child.props).forEach(childProp => { - const componentIdAndProp = `${child.props.id}.${childProp}`; - if ( - has( - componentIdAndProp, - InputGraph.nodes - ) - ) { - newProps[componentIdAndProp] = { - id: child.props.id, - props: { - [childProp]: - child.props[childProp], - }, - }; - } - }); - } - }); - - /* - * Organize props by shared outputs so that we - * only make one request per output component - * (even if there are multiple inputs). - * - * For example, we might render 10 inputs that control - * a single output. If that is the case, we only want - * to make a single call, not 10 calls. - */ - - /* - * In some cases, the new item will be an output - * with its inputs already rendered (not rendered) - * as part of this update. - * For example, a tab with global controls that - * renders different content containers without any - * additional inputs. - * - * In that case, we'll call `updateOutput` with that output - * and just "pretend" that one if its inputs changed. - * - * If we ever add logic that informs the user on - * "which input changed", we'll have to account for this - * special case (no input changed?) - */ - - const outputIds = []; - keys(newProps).forEach(idAndProp => { - if ( - // It's an output - InputGraph.dependenciesOf(idAndProp) - .length === 0 && - /* - * And none of its inputs are generated in this - * request - */ - intersection( - InputGraph.dependantsOf(idAndProp), - keys(newProps) - ).length === 0 - ) { - outputIds.push(idAndProp); - delete newProps[idAndProp]; - } - }); - - // Dispatch updates to inputs - const reducedNodeIds = reduceInputIds( - keys(newProps), - InputGraph - ); - const depOrder = InputGraph.overallOrder(); - const sortedNewProps = sort( - (a, b) => - depOrder.indexOf(a.input) - - depOrder.indexOf(b.input), - reducedNodeIds - ); - sortedNewProps.forEach(function(inputOutput) { - const payload = newProps[inputOutput.input]; - payload.excludedOutputs = - inputOutput.excludedOutputs; - dispatch(notifyObservers(payload)); - }); - - // Dispatch updates to lone outputs - outputIds.forEach(idAndProp => { - const requestUid = uid(); - dispatch( - setRequestQueue( - append( - { - // TODO - Are there any implications of doing this?? - controllerId: null, - status: 'loading', - uid: requestUid, - requestTime: Date.now(), - }, - getState().requestQueue - ) - ) - ); - updateOutput( - idAndProp, - - getState, - requestUid, - dispatch, - changedPropIds - ); - }); - } - } - }; - if (multi) { - Object.entries(data.response).forEach(handleResponse); - } else { - handleResponse([outputIdAndProp, data.response.props]); - } - }); +function doUpdateProps(dispatch, getState, id, updatedProps) { + const {layout, paths} = getState(); + const itempath = getPath(paths, id); + if (!itempath) { + return false; + } + + // This is a callback-generated update. + // Check if this invalidates existing persisted prop values, + // or if persistence changed, whether this updates other props. + const updatedProps2 = prunePersistence( + path(itempath, layout), + updatedProps, + dispatch + ); + + // In case the update contains whole components, see if any of + // those components have props to update to persist user edits. + const {props} = applyPersistence({props: updatedProps2}, dispatch); + + dispatch( + updateProps({ + itempath, + props, + source: 'response', }) - .catch(err => { - const message = `Callback error updating ${ - isMultiOutputProp(payload.output) - ? parseMultipleOutputs(payload.output).join(', ') - : payload.output - }`; - handleAsyncError(err, message, dispatch); - }); + ); + + return props; } -export function handleAsyncError(err, message, dispatch) { - // Handle html error responses - const errText = - err && typeof err.text === 'function' - ? err.text() - : Promise.resolve(err); +function updateChildPaths( + dispatch, + getState, + pendingCallbacks, + id, + children, + oldChildren +) { + const {paths: oldPaths, graphs} = getState(); + const childrenPath = concat(getPath(oldPaths, id), ['props', 'children']); + const paths = computePaths(children, childrenPath, oldPaths); + dispatch(setPaths(paths)); - errText.then(text => { - dispatch( - onError({ - type: 'backEnd', - error: { - message, - html: text, - }, - }) - ); + const cleanedCallbacks = pruneRemovedCallbacks(pendingCallbacks, paths); + + const newCallbacks = getCallbacksInLayout(graphs, paths, children, { + chunkPath: childrenPath, }); + + // Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger + // even due to the deletion of components + const deletedComponentCallbacks = getCallbacksInLayout( + graphs, + oldPaths, + oldChildren, + {removedArrayInputsOnly: true, newPaths: paths, chunkPath: childrenPath} + ); + + const allNewCallbacks = mergePendingCallbacks( + newCallbacks, + deletedComponentCallbacks + ); + return mergePendingCallbacks(cleanedCallbacks, allNewCallbacks); } -export function serialize(state) { - // Record minimal input state in the url - const {graphs, paths, layout} = state; - const {InputGraph} = graphs; - const allNodes = InputGraph.nodes; - const savedState = {}; - keys(allNodes).forEach(nodeId => { - const [componentId, componentProp] = nodeId.split('.'); - /* - * Filter out the outputs, - * and the invisible inputs - */ - if ( - InputGraph.dependenciesOf(nodeId).length > 0 && - has(componentId, paths) - ) { - // Get the property - const propLens = lensPath( - concat(paths[componentId], ['props', componentProp]) +export function notifyObservers({id, props}) { + return async function(dispatch, getState) { + const {graphs, paths, pendingCallbacks} = getState(); + const finalCallbacks = includeObservers( + id, + props, + graphs, + paths, + pendingCallbacks + ); + dispatch(startCallbacks(finalCallbacks)); + }; +} + +function includeObservers(id, props, graphs, paths, pendingCallbacks) { + const changedProps = keys(props); + let finalCallbacks = pendingCallbacks; + + changedProps.forEach(propName => { + const newCBs = getCallbacksByInput(graphs, paths, id, propName); + if (newCBs.length) { + finalCallbacks = mergePendingCallbacks( + finalCallbacks, + followForward(graphs, paths, newCBs) ); - const propValue = view(propLens, layout); - savedState[nodeId] = propValue; } }); + return finalCallbacks; +} - return savedState; +export function handleAsyncError(err, message, dispatch) { + // Handle html error responses + if (err && typeof err.text === 'function') { + err.text().then(text => { + const error = {message, html: text}; + dispatch(onError({type: 'backEnd', error})); + }); + } else { + const error = err instanceof Error ? err : {message, html: err}; + dispatch(onError({type: 'backEnd', error})); + } } diff --git a/dash-renderer/src/actions/isAppReady.js b/dash-renderer/src/actions/isAppReady.js index c83019da54..03465dc2f7 100644 --- a/dash-renderer/src/actions/isAppReady.js +++ b/dash-renderer/src/actions/isAppReady.js @@ -2,11 +2,22 @@ import {path} from 'ramda'; import {isReady} from '@plotly/dash-component-plugins'; import Registry from '../registry'; +import {getPath} from './paths'; +import {stringifyId} from './dependencies'; export default (layout, paths, targets) => { + if (!targets.length) { + return true; + } const promises = []; + + const {events} = paths; + const rendered = new Promise(resolveRendered => { + events.once('rendered', resolveRendered); + }); + targets.forEach(id => { - const pathOfId = paths[id]; + const pathOfId = getPath(paths, id); if (!pathOfId) { return; } @@ -20,7 +31,14 @@ export default (layout, paths, targets) => { const ready = isReady(component); if (ready && typeof ready.then === 'function') { - promises.push(ready); + promises.push( + Promise.race([ + ready, + rendered.then( + () => document.getElementById(stringifyId(id)) && ready + ), + ]) + ); } }); diff --git a/dash-renderer/src/actions/paths.js b/dash-renderer/src/actions/paths.js new file mode 100644 index 0000000000..d9eca5a6be --- /dev/null +++ b/dash-renderer/src/actions/paths.js @@ -0,0 +1,74 @@ +import { + concat, + filter, + find, + forEachObjIndexed, + path, + propEq, + props, +} from 'ramda'; + +import {crawlLayout} from './utils'; + +/* + * state.paths has structure: + * { + * strs: {[id]: path} // for regular string ids + * objs: {[keyStr]: [{values, path}]} // for wildcard ids + * } + * keyStr: sorted keys of the id, joined with ',' into one string + * values: array of values in the id, in order of keys + */ + +export function computePaths(subTree, startingPath, oldPaths, events) { + const {strs: oldStrs, objs: oldObjs} = oldPaths || {strs: {}, objs: {}}; + + const diffHead = path => startingPath.some((v, i) => path[i] !== v); + + const spLen = startingPath.length; + // if we're updating a subtree, clear out all of the existing items + const strs = spLen ? filter(diffHead, oldStrs) : {}; + const objs = {}; + if (spLen) { + forEachObjIndexed((oldValPaths, oldKeys) => { + const newVals = filter(({path}) => diffHead(path), oldValPaths); + if (newVals.length) { + objs[oldKeys] = newVals; + } + }, oldObjs); + } + + crawlLayout(subTree, function assignPath(child, itempath) { + const id = path(['props', 'id'], child); + if (id) { + if (typeof id === 'object') { + const keys = Object.keys(id).sort(); + const values = props(keys, id); + const keyStr = keys.join(','); + const paths = (objs[keyStr] = objs[keyStr] || []); + paths.push({values, path: concat(startingPath, itempath)}); + } else { + strs[id] = concat(startingPath, itempath); + } + } + }); + + // We include an event emitter here because it will be used along with + // paths to determine when the app is ready for callbacks. + return {strs, objs, events: events || oldPaths.events}; +} + +export function getPath(paths, id) { + if (typeof id === 'object') { + const keys = Object.keys(id).sort(); + const keyStr = keys.join(','); + const keyPaths = paths.objs[keyStr]; + if (!keyPaths) { + return false; + } + const values = props(keys, id); + const pathObj = find(propEq('values', values), keyPaths); + return pathObj && pathObj.path; + } + return paths.strs[id]; +} diff --git a/dash-renderer/src/actions/utils.js b/dash-renderer/src/actions/utils.js new file mode 100644 index 0000000000..3e85052ce3 --- /dev/null +++ b/dash-renderer/src/actions/utils.js @@ -0,0 +1,79 @@ +import {append, concat, has, path, 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 + '/'; +} + +const propsChildren = ['props', 'children']; + +// crawl a layout object or children array, apply a function on every object +export const crawlLayout = (object, func, currentPath = []) => { + if (Array.isArray(object)) { + // children array + object.forEach((child, i) => { + crawlLayout(child, func, append(i, currentPath)); + }); + } else if (type(object) === 'Object') { + func(object, currentPath); + + const children = path(propsChildren, object); + if (children) { + const newPath = concat(currentPath, propsChildren); + crawlLayout(children, func, newPath); + } + } +}; + +// There are packages for this but it's simple enough, I just +// adapted it from https://gist.github.com/mudge/5830382 +export class EventEmitter { + constructor() { + this._ev = {}; + } + on(event, listener) { + const events = (this._ev[event] = this._ev[event] || []); + events.push(listener); + return () => this.removeListener(event, listener); + } + removeListener(event, listener) { + const events = this._ev[event]; + if (events) { + const idx = events.indexOf(listener); + if (idx > -1) { + events.splice(idx, 1); + } + } + } + emit(event, ...args) { + const events = this._ev[event]; + if (events) { + events.forEach(listener => listener.apply(this, args)); + } + } + once(event, listener) { + const remove = this.on(event, (...args) => { + remove(); + listener.apply(this, args); + }); + } +} diff --git a/dash-renderer/src/components/core/DocumentTitle.react.js b/dash-renderer/src/components/core/DocumentTitle.react.js index ef6aa48f64..46eba06bfc 100644 --- a/dash-renderer/src/components/core/DocumentTitle.react.js +++ b/dash-renderer/src/components/core/DocumentTitle.react.js @@ -1,5 +1,4 @@ import {connect} from 'react-redux'; -import {any} from 'ramda'; import {Component} from 'react'; import PropTypes from 'prop-types'; @@ -12,7 +11,7 @@ class DocumentTitle extends Component { } UNSAFE_componentWillReceiveProps(props) { - if (any(r => r.status === 'loading', props.requestQueue)) { + if (props.pendingCallbacks.length) { document.title = 'Updating...'; } else { document.title = this.state.initialTitle; @@ -29,9 +28,9 @@ class DocumentTitle extends Component { } DocumentTitle.propTypes = { - requestQueue: PropTypes.array.isRequired, + pendingCallbacks: PropTypes.array.isRequired, }; export default connect(state => ({ - requestQueue: state.requestQueue, + pendingCallbacks: state.pendingCallbacks, }))(DocumentTitle); diff --git a/dash-renderer/src/components/core/Loading.react.js b/dash-renderer/src/components/core/Loading.react.js index f0701eb38e..999684a8dc 100644 --- a/dash-renderer/src/components/core/Loading.react.js +++ b/dash-renderer/src/components/core/Loading.react.js @@ -1,19 +1,18 @@ import {connect} from 'react-redux'; -import {any} from 'ramda'; import React from 'react'; import PropTypes from 'prop-types'; function Loading(props) { - if (any(r => r.status === 'loading', props.requestQueue)) { + if (props.pendingCallbacks.length) { return
; } return null; } Loading.propTypes = { - requestQueue: PropTypes.array.isRequired, + pendingCallbacks: PropTypes.array.isRequired, }; export default connect(state => ({ - requestQueue: state.requestQueue, + pendingCallbacks: state.pendingCallbacks, }))(Loading); diff --git a/dash-renderer/src/components/core/Toolbar.react.js b/dash-renderer/src/components/core/Toolbar.react.js index 38d947aef7..7d5941658d 100644 --- a/dash-renderer/src/components/core/Toolbar.react.js +++ b/dash-renderer/src/components/core/Toolbar.react.js @@ -33,7 +33,7 @@ function UnconnectedToolbar(props) { }, styles.parentSpanStyle )} - onClick={() => dispatch(undo())} + onClick={() => dispatch(undo)} >
dispatch(redo())} + onClick={() => dispatch(redo)} >
{ + const el = useRef(null); - componentDidMount() { - this.updateViz(); - } + const viz = useRef(null); - componentDidUpdate() { - this.updateViz(); - } + const makeViz = () => { + viz.current = new Viz({Module, render}); + }; - render() { - return
; + if (!viz.current) { + makeViz(); } - updateViz() { - this.viz = this.viz || new Viz({Module, render}); - - const {dependenciesRequest} = this.props; + useEffect(() => { + const {callbacks} = graphs; const elements = {}; - const callbacks = []; - const links = dependenciesRequest.content.map(({inputs, output}, i) => { - callbacks.push(`cb${i};`); - function recordAndReturn([id, property]) { - elements[id] = elements[id] || {}; - elements[id][property] = true; - return `"${id}.${property}"`; + const callbacksOut = []; + const links = callbacks.map(({inputs, outputs}, i) => { + callbacksOut.push(`cb${i};`); + function recordAndReturn({id, property}) { + const idClean = stringifyId(id) + .replace(/[\{\}".;\[\]()]/g, '') + .replace(/:/g, '-') + .replace(/,/g, '_'); + elements[idClean] = elements[idClean] || {}; + elements[idClean][property] = true; + return `"${idClean}.${property}"`; } - const out_nodes = output - .replace(/^\.\./, '') - .replace(/\.\.$/, '') - .split('...') - .map(o => recordAndReturn(o.split('.'))) - .join(', '); - const in_nodes = inputs - .map(({id, property}) => recordAndReturn([id, property])) - .join(', '); + const out_nodes = outputs.map(recordAndReturn).join(', '); + const in_nodes = inputs.map(recordAndReturn).join(', '); return `{${in_nodes}} -> cb${i} -> {${out_nodes}};`; }); @@ -58,7 +48,7 @@ class CallbackGraphContainer extends Component { graph [penwidth=0]; subgraph callbacks { node [shape=circle, width=0.3, label="", color="#00CC96"]; - ${callbacks.join('\n')} } + ${callbacksOut.join('\n')} } ${Object.entries(elements) .map( @@ -74,26 +64,26 @@ class CallbackGraphContainer extends Component { ${links.join('\n')} }`; - const el = this.refs.el; - - this.viz + viz.current .renderSVGElement(dot) .then(vizEl => { - el.innerHTML = ''; - el.appendChild(vizEl); + el.current.innerHTML = ''; + el.current.appendChild(vizEl); }) .catch(e => { // https://github.com/mdaines/viz.js/wiki/Caveats - this.viz = new Viz({Module, render}); + makeViz(); // eslint-disable-next-line no-console console.error(e); - el.innerHTML = 'Error creating callback graph'; + el.current.innerHTML = 'Error creating callback graph'; }); - } -} + }); + + return
; +}; CallbackGraphContainer.propTypes = { - dependenciesRequest: PropTypes.object, + graphs: PropTypes.object, }; export {CallbackGraphContainer}; diff --git a/dash-renderer/src/components/error/FrontEnd/FrontEndError.react.js b/dash-renderer/src/components/error/FrontEnd/FrontEndError.react.js index 813cf67b9b..a4cac920ea 100644 --- a/dash-renderer/src/components/error/FrontEnd/FrontEndError.react.js +++ b/dash-renderer/src/components/error/FrontEnd/FrontEndError.react.js @@ -4,7 +4,7 @@ import {Component} from 'react'; import CollapseIcon from '../icons/CollapseIcon.svg'; import PropTypes from 'prop-types'; import '../Percy.css'; -import {urlBase} from '../../../utils'; +import {urlBase} from '../../../actions/utils'; import werkzeugCss from '../werkzeugcss'; @@ -101,8 +101,8 @@ function UnconnectedErrorContent({error, base}) { - {error.stack.split('\n').map(line => ( -

{line}

+ {error.stack.split('\n').map((line, i) => ( +

{line}

))}
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 (
- -
{this.props.children}
+ +
{children}
); @@ -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