From dfc278efe45ec36cb04ba73378622eca8e619dcf Mon Sep 17 00:00:00 2001 From: Sergey Pikhovkin Date: Sun, 30 Sep 2018 23:27:01 +0300 Subject: [PATCH] Init --- .circleci/config.yml | 74 ++ .flake8 | 2 + .github/ISSUE_TEMPLATE.md | 7 + .gitignore | 22 + .pylintrc | 467 ++++++++ .pylintrc37 | 568 +++++++++ LICENSE | 21 + MANIFEST.in | 2 + README.md | 46 + dash/__init__.py | 6 + dash/_utils.py | 74 ++ dash/authentication.py | 0 dash/dash.py | 944 +++++++++++++++ dash/dependencies.py | 26 + dash/development/__init__.py | 2 + dash/development/base_component.py | 839 +++++++++++++ dash/development/component_loader.py | 109 ++ dash/exceptions.py | 66 ++ dash/resources.py | 143 +++ dash/urls.py | 18 + dash/version.py | 1 + dev-requirements.txt | 12 + pytest.ini | 2 + requirements.txt | 5 + setup.py | 51 + tests/__init__.py | 0 tests/development/TestReactComponent.react.js | 114 ++ .../TestReactComponentRequired.react.js | 19 + tests/development/__init__.py | 0 tests/development/flow_metadata_test.json | 368 ++++++ tests/development/metadata_required_test.json | 20 + tests/development/metadata_test.json | 260 ++++ tests/development/metadata_test.py | 83 ++ tests/development/test_base_component.py | 1042 +++++++++++++++++ tests/development/test_component_loader.py | 221 ++++ tests/django_project/__init__.py | 0 tests/django_project/dynamic_dash/__init__.py | 0 tests/django_project/dynamic_dash/apps.py | 11 + .../dynamic_dash/migrations/__init__.py | 0 tests/django_project/dynamic_dash/models.py | 6 + .../static/dynamic_dash/assets/load_first.js | 7 + .../dynamic_dash/assets/load_ignored.js | 1 + .../dynamic_dash/assets/nested_css/nested.css | 3 + .../assets/nested_js/load_after.js | 1 + .../assets/nested_js/load_after1.js | 1 + .../assets/nested_js/load_after10.js | 1 + .../assets/nested_js/load_after11.js | 1 + .../assets/nested_js/load_after2.js | 1 + .../assets/nested_js/load_after3.js | 1 + .../assets/nested_js/load_after4.js | 1 + .../assets/nested_js/load_last.js | 1 + .../static/dynamic_dash/assets/reset.css | 2 + tests/django_project/dynamic_dash/views.py | 373 ++++++ tests/django_project/manage.py | 22 + tests/django_project/project/__init__.py | 0 tests/django_project/project/settings.py | 87 ++ tests/django_project/project/urls.py | 15 + tests/django_project/project/wsgi.py | 16 + .../static/dynamic_dash/assets/load_first.js | 7 + .../dynamic_dash/assets/load_ignored.js | 1 + .../dynamic_dash/assets/nested_css/nested.css | 3 + .../assets/nested_js/load_after.js | 1 + .../assets/nested_js/load_after1.js | 1 + .../assets/nested_js/load_after10.js | 1 + .../assets/nested_js/load_after11.js | 1 + .../assets/nested_js/load_after2.js | 1 + .../assets/nested_js/load_after3.js | 1 + .../assets/nested_js/load_after4.js | 1 + .../assets/nested_js/load_last.js | 1 + .../static/dynamic_dash/assets/reset.css | 2 + .../static/static_dash/assets/load_first.js | 7 + .../static/static_dash/assets/load_ignored.js | 1 + .../static_dash/assets/nested_css/nested.css | 3 + .../assets/nested_js/load_after.js | 1 + .../assets/nested_js/load_after1.js | 1 + .../assets/nested_js/load_after10.js | 1 + .../assets/nested_js/load_after11.js | 1 + .../assets/nested_js/load_after2.js | 1 + .../assets/nested_js/load_after3.js | 1 + .../assets/nested_js/load_after4.js | 1 + .../static_dash/assets/nested_js/load_last.js | 1 + .../static/static_dash/assets/reset.css | 2 + tests/django_project/static_dash/__init__.py | 0 tests/django_project/static_dash/apps.py | 11 + .../static_dash/migrations/__init__.py | 0 tests/django_project/static_dash/models.py | 6 + .../static/static_dash/assets/load_first.js | 7 + .../static/static_dash/assets/load_ignored.js | 1 + .../static_dash/assets/nested_css/nested.css | 3 + .../assets/nested_js/load_after.js | 1 + .../assets/nested_js/load_after1.js | 1 + .../assets/nested_js/load_after10.js | 1 + .../assets/nested_js/load_after11.js | 1 + .../assets/nested_js/load_after2.js | 1 + .../assets/nested_js/load_after3.js | 1 + .../assets/nested_js/load_after4.js | 1 + .../static_dash/assets/nested_js/load_last.js | 1 + .../static/static_dash/assets/reset.css | 2 + tests/django_project/static_dash/views.py | 371 ++++++ .../django_project/tests/IntegrationTests.py | 61 + tests/django_project/tests/__init__.py | 0 .../django_project/tests/test_integration.py | 294 +++++ tests/django_project/tests/utils.py | 82 ++ tests/package.json | 11 + tests/test_react.py | 497 ++++++++ tests/test_resources.py | 164 +++ tox.ini | 137 +++ 107 files changed, 7882 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .flake8 create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 .pylintrc37 create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 dash/__init__.py create mode 100644 dash/_utils.py create mode 100644 dash/authentication.py create mode 100644 dash/dash.py create mode 100644 dash/dependencies.py create mode 100644 dash/development/__init__.py create mode 100644 dash/development/base_component.py create mode 100644 dash/development/component_loader.py create mode 100644 dash/exceptions.py create mode 100644 dash/resources.py create mode 100644 dash/urls.py create mode 100644 dash/version.py create mode 100644 dev-requirements.txt create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/development/TestReactComponent.react.js create mode 100644 tests/development/TestReactComponentRequired.react.js create mode 100644 tests/development/__init__.py create mode 100644 tests/development/flow_metadata_test.json create mode 100644 tests/development/metadata_required_test.json create mode 100644 tests/development/metadata_test.json create mode 100644 tests/development/metadata_test.py create mode 100644 tests/development/test_base_component.py create mode 100644 tests/development/test_component_loader.py create mode 100644 tests/django_project/__init__.py create mode 100644 tests/django_project/dynamic_dash/__init__.py create mode 100644 tests/django_project/dynamic_dash/apps.py create mode 100644 tests/django_project/dynamic_dash/migrations/__init__.py create mode 100644 tests/django_project/dynamic_dash/models.py create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_first.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_ignored.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_css/nested.css create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after1.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after10.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after11.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after2.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after3.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after4.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_last.js create mode 100644 tests/django_project/dynamic_dash/static/dynamic_dash/assets/reset.css create mode 100644 tests/django_project/dynamic_dash/views.py create mode 100644 tests/django_project/manage.py create mode 100644 tests/django_project/project/__init__.py create mode 100644 tests/django_project/project/settings.py create mode 100644 tests/django_project/project/urls.py create mode 100644 tests/django_project/project/wsgi.py create mode 100644 tests/django_project/static/dynamic_dash/assets/load_first.js create mode 100644 tests/django_project/static/dynamic_dash/assets/load_ignored.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_css/nested.css create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after1.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after10.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after11.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after2.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after3.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_after4.js create mode 100644 tests/django_project/static/dynamic_dash/assets/nested_js/load_last.js create mode 100644 tests/django_project/static/dynamic_dash/assets/reset.css create mode 100644 tests/django_project/static/static_dash/assets/load_first.js create mode 100644 tests/django_project/static/static_dash/assets/load_ignored.js create mode 100644 tests/django_project/static/static_dash/assets/nested_css/nested.css create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after1.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after10.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after11.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after2.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after3.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_after4.js create mode 100644 tests/django_project/static/static_dash/assets/nested_js/load_last.js create mode 100644 tests/django_project/static/static_dash/assets/reset.css create mode 100644 tests/django_project/static_dash/__init__.py create mode 100644 tests/django_project/static_dash/apps.py create mode 100644 tests/django_project/static_dash/migrations/__init__.py create mode 100644 tests/django_project/static_dash/models.py create mode 100644 tests/django_project/static_dash/static/static_dash/assets/load_first.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/load_ignored.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_css/nested.css create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after1.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after10.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after11.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after2.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after3.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after4.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/nested_js/load_last.js create mode 100644 tests/django_project/static_dash/static/static_dash/assets/reset.css create mode 100644 tests/django_project/static_dash/views.py create mode 100644 tests/django_project/tests/IntegrationTests.py create mode 100644 tests/django_project/tests/__init__.py create mode 100644 tests/django_project/tests/test_integration.py create mode 100644 tests/django_project/tests/utils.py create mode 100644 tests/package.json create mode 100644 tests/test_react.py create mode 100644 tests/test_resources.py create mode 100644 tox.ini diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..c286601 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,74 @@ +version: 2 + +jobs: + "python-2.7": &test-template + docker: + - image: circleci/python:2.7-stretch-browsers + environment: + REQUIREMENTS_FILE: dev-requirements.txt + PYLINTRC: .pylintrc + TOX: py27,py27dj{109,110,111,200,201} + TOX_PYTHON_27: python + + steps: + - checkout + + - run: + name: Write deps cache key + command: cat "$REQUIREMENTS_FILE" > reqs.txt + + - run: + name: Write job name + command: echo $CIRCLE_JOB > circlejob.txt + + - restore_cache: + key: deps1-{{ .Branch }}-{{ checksum "reqs.txt" }}-{{ checksum ".circleci/config.yml" }}-{{ checksum "circlejob.txt" }} + + - run: + name: Install dependencies + command: | + sudo pip install virtualenv + virtualenv venv + . venv/bin/activate + pip install tox + + - save_cache: + key: deps1-{{ .Branch }}-{{ checksum "reqs.txt" }}-{{ checksum ".circleci/config.yml" }}-{{ checksum "circlejob.txt" }} + paths: + - "venv" + - ".tox" + + - run: + name: Run tox + command: | + . venv/bin/activate + tox tox.ini -e $TOX + + "python-3.6": + <<: *test-template + docker: + - image: circleci/python:3.6-stretch-browsers + environment: + REQUIREMENTS_FILE: dev-requirements.txt + PYLINTRC: .pylintrc + TOX: py36,py36dj{109,110,111,200,201} + TOX_PYTHON_36: python + + "python-3.7": + <<: *test-template + docker: + - image: circleci/python:3.7-stretch-browsers + environment: + REQUIREMENTS_FILE: dev-requirements.txt + PYLINTRC37: .pylintrc37 + TOX: py37,py37dj{109,110,111,200,201} + TOX_PYTHON_37: python + + +workflows: + version: 2 + build: + jobs: + - "python-2.7" + - "python-3.6" + - "python-3.7" diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..dd94b77 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,7 @@ +Thanks so much for your interest in Dash! +Before you post an issue here, could you quickly search for your +issue in the Dash community forum? https://community.plot.ly/c/dash +GitHub issues are great for bug reports, the community forum is great for +implementation questions. When in doubt, creating feel free to just post the +issue here :) +Thanks, and welcome to Dash! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5230a5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +*.pyc +*.ipynb_notebooks +*.ipynb +ignore +*~ +venv +build/ +dist/ +lib/ +node_modules/ +.npm +vv/ +venv/ +*.pyc +*.egg-info +*.log +.DS_Store +dist +*egg-info* +npm-debug* +/.tox +.idea diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..eab8cff --- /dev/null +++ b/.pylintrc @@ -0,0 +1,467 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=no + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=fixme, + missing-docstring, + invalid-name, + useless-object-inheritance, + possibly-unused-variable +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/.pylintrc37 b/.pylintrc37 new file mode 100644 index 0000000..06e164b --- /dev/null +++ b/.pylintrc37 @@ -0,0 +1,568 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=no + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=invalid-name, + missing-docstring, + print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + locally-enabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + fixme, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + no-else-return, + useless-object-inheritance, + possibly-unused-variable, + too-many-lines + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception". +overgeneral-exceptions=Exception diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c1b83a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Sergei Pikhovkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..04f196a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include LICENSE diff --git a/README.md b/README.md new file mode 100644 index 0000000..8431e6e --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# dj-plotly-dash + +[![CircleCI](https://img.shields.io/circleci/project/github/pikhovkin/dj-plotly-dash.svg)](https://circleci.com/gh/pikhovkin/dj-plotly-dash) +[![PyPI](https://img.shields.io/pypi/v/dj-plotly-dash.svg)](https://pypi.org/project/dj-plotly-dash/) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dj-plotly-dash.svg) +[![framework - Django](https://img.shields.io/badge/framework-Django-0C3C26.svg)](https://www.djangoproject.com/) +[![PyPI - License](https://img.shields.io/pypi/l/dj-plotly-dash.svg)](./LICENSE) + + +#### Dash is a Python framework for building analytical web applications. No JavaScript required. + +It's fork of Plotly [Dash](https://github.com/plotly/dash). + +Here’s [a example of view of Django Dash App](https://gist.github.com/pikhovkin/6ec23d425b12b720651942fd6a5cdf13) ([original example with Flask](https://gist.github.com/chriddyp/3d2454905d8f01886d651f207e2419f0)) that ties a Dropdown to a D3.js Plotly Graph. +As the user selects a value in the Dropdown, the application code dynamically +exports data from Google Finance into a Pandas DataFrame. + +![Sample Dash App](https://user-images.githubusercontent.com/1280389/30086128-9bb4a28e-9267-11e7-8fe4-bbac7d53f2b0.gif) + +Dash app code is declarative and reactive, which makes it easy to build complex apps that contain many interactive elements. Here’s an example ([original example with Flask](https://gist.github.com/chriddyp/9b2b3e8a6c67697279d3724dce5dab3c)) with 5 inputs, 3 outputs, and cross filtering. This app was composed in just 160 lines of code, all of which were Python. + +![crossfiltering dash app](https://user-images.githubusercontent.com/1280389/30086123-97c58bde-9267-11e7-98a0-7f626de5199a.gif) + +Dash uses [Plotly.js](https://github.com/plotly/plotly.js) for charting. Over 35 chart types are supported, including maps. + +Dash isn't just for dashboards. You have full control over the look and feel of your applications. Here's a Dash app that's styled to look like a PDF report. + +To learn more about Dash, read the [extensive announcement letter](https://medium.com/@plotlygraphs/introducing-dash-5ecf7191b503) or [jump in with the user guide](https://plot.ly/dash). + +### Usage + +See examples of usage in `tests/django_project` + +### Installation + + pip install dj-plotly-dash + pip install dash_core_components>=0.30.2 --no-deps + pip install dash_html_components>=0.13.2 --no-deps + +### Documentation + +View the [Dash User Guide](https://plot.ly/dash). It's chock-full of examples, pro tips, and guiding principles. + +### Licensing + +MIT diff --git a/dash/__init__.py b/dash/__init__.py new file mode 100644 index 0000000..027dbb8 --- /dev/null +++ b/dash/__init__.py @@ -0,0 +1,6 @@ +from .dash import Dash, BaseDashView # noqa: F401 +from . import dependencies # noqa: F401 +from . import development # noqa: F401 +from . import exceptions # noqa: F401 +from . import resources # noqa: F401 +from .version import __version__ # noqa: F401 diff --git a/dash/_utils.py b/dash/_utils.py new file mode 100644 index 0000000..17dc247 --- /dev/null +++ b/dash/_utils.py @@ -0,0 +1,74 @@ +def interpolate_str(template, **data): + s = template + for k, v in data.items(): + key = '{%' + k + '%}' + s = s.replace(key, v) + return s + + +def format_tag(tag_name, attributes, inner='', closed=False, opened=False): + tag = '<{tag} {attributes}' + if closed: + tag += '/>' + elif opened: + tag += '>' + else: + tag += '>' + inner + '' + return tag.format( + tag=tag_name, + attributes=' '.join([ + '{}="{}"'.format(k, v) for k, v in attributes.items()])) + + +def get_asset_path( + requests_pathname, + routes_pathname, + asset_path, + asset_url_path): + + i = requests_pathname.rfind(routes_pathname) + req = requests_pathname[:i] + + return '/'.join([ + # Only take the first part of the pathname + req, + asset_url_path, + asset_path + ]) + + +class AttributeDict(dict): + """ + Dictionary subclass enabling attribute lookup/assignment of keys/values. + + For example:: + >>> m = AttributeDict({'foo': 'bar'}) + >>> m.foo + 'bar' + >>> m.foo = 'not bar' + >>> m['foo'] + 'not bar' + ``AttributeDict`` objects also provide ``.first()`` which acts like + ``.get()`` but accepts multiple keys as arguments, and returns the value of + the first hit, e.g.:: + >>> m = AttributeDict({'foo': 'bar', 'biz': 'baz'}) + >>> m.first('wrong', 'incorrect', 'foo', 'biz') + 'bar' + """ + + def __setattr__(self, key, value): + self[key] = value + + def __getattr__(self, key): + try: + return self[key] + except KeyError: + # to conform with __getattr__ spec + raise AttributeError(key) + + # pylint: disable=inconsistent-return-statements + def first(self, *names): + for name in names: + value = self.get(name) + if value: + return value diff --git a/dash/authentication.py b/dash/authentication.py new file mode 100644 index 0000000..e69de29 diff --git a/dash/dash.py b/dash/dash.py new file mode 100644 index 0000000..0e0a1f0 --- /dev/null +++ b/dash/dash.py @@ -0,0 +1,944 @@ +from __future__ import print_function + +import os +import sys +import collections +import importlib +import json +import pkgutil +from functools import wraps +import re + +import plotly +import dash_renderer +import six + +from django.contrib.staticfiles.utils import get_files +from django.contrib.staticfiles.storage import staticfiles_storage +from django.http import HttpResponse, JsonResponse as BaseJsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View + +from .dependencies import Event, Input, Output, State +from .resources import Scripts, Css +from .development.base_component import Component +from . import exceptions +from ._utils import AttributeDict as _AttributeDict +from ._utils import interpolate_str as _interpolate +from ._utils import format_tag as _format_tag + + +__all__ = ( + 'JsonResponse', + 'MetaDashView', + 'Dash', + 'BaseDashView' +) + + +class JsonResponse(BaseJsonResponse): + def __init__(self, data, encoder=plotly.utils.PlotlyJSONEncoder, safe=False, + json_dumps_params=None, **kwargs): + super(JsonResponse, self).__init__(data, encoder=encoder, safe=safe, + json_dumps_params=json_dumps_params, **kwargs) + + +class MetaDashView(type): + def __new__(cls, name, bases, attrs): + new_cls = super(MetaDashView, cls).__new__(cls, name, bases, attrs) + + if new_cls.__dict__.get('dash_name', ''): + new_cls._dashes[new_cls.__dict__['dash_name']] = new_cls # pylint: disable=protected-access + dash_prefix = getattr(new_cls, 'dash_prefix', '').strip() + if dash_prefix: + # pylint: disable=protected-access + new_cls._dashes[dash_prefix + new_cls.__dict__['dash_name']] = new_cls + + return new_cls + + +_default_index = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + + {%app_entry%} + + + +''' + +_app_entry = ''' +
+
+ Loading... +
+
+''' + +_re_index_entry = re.compile(r'{%app_entry%}') +_re_index_config = re.compile(r'{%config%}') +_re_index_scripts = re.compile(r'{%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.*"') + + +# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-locals +class Dash(object): + # pylint: disable=unused-argument + def __init__(self, url_base_pathname='/', + meta_tags=None, + index_string=_default_index, + external_scripts=None, + external_stylesheets=None, + assets_folder=None, + assets_ignore=None, + components_cache_max_age=None, + **kwargs): + self._assets_folder = assets_folder + + self.url_base_pathname = url_base_pathname + self.config = _AttributeDict({ + 'suppress_callback_exceptions': False, + 'routes_pathname_prefix': url_base_pathname, + 'requests_pathname_prefix': url_base_pathname, + 'assets_external_path': None, + 'components_cache_max_age': components_cache_max_age or 2678400 + }) + + # list of dependencies + self.callback_map = {} + + self._index_string = '' + self.index_string = index_string + self._meta_tags = meta_tags or [] + self._favicon = None + + # static files from the packages + self.css = Css() + self.scripts = Scripts() + + self._external_scripts = external_scripts or [] + self._external_stylesheets = external_stylesheets or [] + + self.assets_ignore = assets_ignore + + self.registered_paths = {} + + self._layout = None + self._cached_layout = None + self.routes = [] + + self._dev_tools = _AttributeDict({ + 'serve_dev_bundles': False + }) + + @property + def index_string(self): + return self._index_string + + @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 Exception( + 'Did you forget to include {} in your index string ?'.format( + ', '.join('{%' + x + '%}' for x in missing) + ) + ) + self._index_string = value + + @property + def layout(self): + return self._layout + + def _layout_value(self): + if isinstance(self._layout, collections.Callable): + self._cached_layout = self._layout() + else: + self._cached_layout = self._layout + return self._cached_layout + + @layout.setter + def layout(self, value): + if (not isinstance(value, Component) and + not isinstance(value, collections.Callable)): + raise Exception( + '' + 'Layout must be a dash component ' + 'or a function that returns ' + 'a dash component.') + + self._layout = value + + self._validate_layout() + + layout_value = self._layout_value() + # pylint: disable=protected-access + self.css._update_layout(layout_value) + self.scripts._update_layout(layout_value) + + def _config(self): + return { + 'url_base_pathname': self.url_base_pathname, + 'requests_pathname_prefix': self.config.requests_pathname_prefix + } + + def _collect_and_register_resources(self, resources): + # now needs the app context. + # template in the necessary component suite JS bundles + # add the version number of the package as a query parameter + # for cache busting + def _relative_url_path(relative_package_path='', namespace=''): + + # track the registered packages + if namespace in self.registered_paths: + self.registered_paths[namespace].append(relative_package_path) + else: + self.registered_paths[namespace] = [relative_package_path] + + module_path = os.path.join( + os.path.dirname(sys.modules[namespace].__file__), + relative_package_path) + + modified = int(os.stat(module_path).st_mtime) + + return '{}_dash-component-suites/{}/{}?v={}&m={}'.format( + self.config['requests_pathname_prefix'], + namespace, + relative_package_path, + importlib.import_module(namespace).__version__, + modified + ) + + srcs = [] + for resource in resources: + if 'relative_package_path' in resource: + if isinstance(resource['relative_package_path'], str): + srcs.append(_relative_url_path(**resource)) + else: + for rel_path in resource['relative_package_path']: + srcs.append(_relative_url_path( + relative_package_path=rel_path, + namespace=resource['namespace'] + )) + elif 'external_url' in resource: + if isinstance(resource['external_url'], str): + srcs.append(resource['external_url']) + else: + for url in resource['external_url']: + srcs.append(url) + elif 'absolute_path' in resource: + raise Exception( + 'Serving files from absolute_path isn\'t supported yet' + ) + elif 'asset_path' in resource: + static_url = resource['asset_path'] + # Add a bust query param + static_url += '?m={}'.format(resource['ts']) + srcs.append(static_url) + return srcs + + def _generate_css_dist_html(self): + links = self._external_stylesheets + \ + self._collect_and_register_resources(self.css.get_all_css()) + + return '\n'.join([ + _format_tag('link', link, opened=True) + if isinstance(link, dict) + else ''.format(link) + for link in links + ]) + + def _generate_scripts_html(self): + # Dash renderer has dependencies like React which need to be rendered + # before every other script. However, the dash renderer bundle + # itself needs to be rendered after all of the component's + # scripts have rendered. + # The rest of the scripts can just be loaded after React but before + # dash renderer. + # pylint: disable=protected-access + srcs = self._collect_and_register_resources( + self.scripts._resources._filter_resources( + dash_renderer._js_dist_dependencies, + dev_bundles=self._dev_tools.serve_dev_bundles + )) + self._external_scripts + self._collect_and_register_resources( + self.scripts.get_all_scripts( + dev_bundles=self._dev_tools.serve_dev_bundles) + + self.scripts._resources._filter_resources( + dash_renderer._js_dist, + dev_bundles=self._dev_tools.serve_dev_bundles + )) + + return '\n'.join([ + _format_tag('script', src) + if isinstance(src, dict) + else ''.format(src) + for src in srcs + ]) + + def _generate_config_html(self, **kwargs): + config = self._config() + config.update(kwargs) + return ( + '' + ).format(json.dumps(config, cls=plotly.utils.PlotlyJSONEncoder)) + + def _generate_meta_html(self): + has_ie_compat = any( + x.get('http-equiv', '') == 'X-UA-Compatible' + for x in self._meta_tags) + has_charset = any('charset' in x for x in self._meta_tags) + + tags = [] + if not has_ie_compat: + tags.append('') + if not has_charset: + tags.append('') + + tags = tags + [ + _format_tag('meta', x, opened=True) for x in self._meta_tags + ] + + return '\n '.join(tags) + + def index(self, *args, **kwargs): # pylint: disable=unused-argument + if self._assets_folder: + self._walk_assets_directory() + + scripts = self._generate_scripts_html() + css = self._generate_css_dist_html() + config = self._generate_config_html() + metas = self._generate_meta_html() + title = getattr(self, 'title', 'Dash') + if self._favicon: + favicon = ''.format(self._favicon) + else: + favicon = '' + + index = self.interpolate_index( + metas=metas, title=title, css=css, config=config, + scripts=scripts, app_entry=_app_entry, favicon=favicon) + + 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'), + ) + missing = [missing for check, missing in checks if not check] + + if missing: + plural = 's' if len(missing) > 1 else '' + raise Exception( + 'Missing element{pl} {ids} in index.'.format( + ids=', '.join(missing), + pl=plural + ) + ) + + return index + + def interpolate_index(self, + metas='', title='', css='', config='', + scripts='', app_entry='', favicon=''): + """ + Called to create the initial HTML string that is loaded on page. + Override this method to provide you own custom HTML. + + :Example: + + class MyDash(dash.Dash): + def interpolate_index(self, **kwargs): + return ''' + + + + My App + + +
My custom header
+ {app_entry} + {config} + {scripts} + + + + '''.format( + app_entry=kwargs.get('app_entry'), + config=kwargs.get('config'), + scripts=kwargs.get('scripts')) + + :param metas: Collected & formatted meta tags. + :param title: The title of the app. + :param css: Collected & formatted css dependencies as tags. + :param config: Configs needed by dash-renderer. + :param scripts: Collected & formatted scripts tags. + :param app_entry: Where the app will render. + :param favicon: A favicon tag if found in assets folder. + :return: The interpolated HTML string for the index. + """ + return _interpolate(self.index_string, + metas=metas, + title=title, + css=css, + config=config, + scripts=scripts, + favicon=favicon, + app_entry=app_entry) + + def dependencies(self, *args, **kwargs): # pylint: disable=unused-argument + return [ + { + 'output': { + 'id': k.split('.')[0], + 'property': k.split('.')[1] + }, + 'inputs': v['inputs'], + 'state': v['state'], + 'events': v['events'] + } for k, v in list(self.callback_map.items()) + ] + + # pylint: disable=unused-argument, no-self-use + def react(self, *args, **kwargs): + raise exceptions.DashException( + 'Yo! `react` is no longer used. \n' + 'Use `callback` instead. `callback` has a new syntax too, ' + 'so make sure to call `help(app.callback)` to learn more.') + + # pylint: disable=unused-argument + def serve_component_suites(self, package_name, path_in_package_dist, *args, **kwargs): + """ Serve the JS bundles for each package + """ + if package_name not in self.registered_paths: + raise exceptions.InvalidResourceError( + 'Error loading dependency.\n' + '"{}" is not a registered library.\n' + 'Registered libraries are: {}' + .format(package_name, list(self.registered_paths.keys()))) + + elif path_in_package_dist not in self.registered_paths[package_name]: + raise exceptions.InvalidResourceError( + '"{}" 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 + ) + ) + + return pkgutil.get_data(package_name, path_in_package_dist) + + def serve_routes(self, *args, **kwargs): # pylint: disable=unused-argument + return self.routes + + def _validate_callback(self, output, inputs, state, events): + # pylint: disable=too-many-branches + layout = self._cached_layout or self._layout_value() + + if (layout is None and + not self.config.first('suppress_callback_exceptions', + 'supress_callback_exceptions')): + # Without a layout, we can't do validation on the IDs and + # properties of the elements in the callback. + raise exceptions.LayoutIsNotDefined(''' + 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 + `app.config['suppress_callback_exceptions']=True` + '''.replace(' ', '')) + + for args, obj, name in [([output], Output, 'Output'), + (inputs, Input, 'Input'), + (state, State, 'State'), + (events, Event, 'Event')]: + + if not isinstance(args, list): + raise exceptions.IncorrectTypeException( + 'The {} argument `{}` is ' + 'not a list of `dash.dependencies.{}`s.'.format( + name.lower(), str(args), name + )) + + for arg in args: + if not isinstance(arg, obj): + raise exceptions.IncorrectTypeException( + 'The {} argument `{}` is ' + 'not of type `dash.{}`.'.format( + name.lower(), str(arg), name + )) + + if (not self.config.first('suppress_callback_exceptions', + 'supress_callback_exceptions') and + arg.component_id not in layout and + arg.component_id != getattr(layout, 'id', None)): + raise exceptions.NonExistantIdException(''' + Attempting to assign a callback to the + component with the id "{}" but no + components with id "{}" exist in the + app\'s layout.\n\n + Here is a list of IDs in layout:\n{}\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 + `app.config['suppress_callback_exceptions']=True`. + '''.format( + arg.component_id, + arg.component_id, + list(layout.keys()) + ( + [] if not hasattr(layout, 'id') else + [layout.id] + ) + ).replace(' ', '')) + + if not self.config.first('suppress_callback_exceptions', + 'supress_callback_exceptions'): + + if getattr(layout, 'id', None) == arg.component_id: + component = layout + else: + component = layout[arg.component_id] + + if (hasattr(arg, 'component_property') and + arg.component_property not in + component.available_properties and not + any(arg.component_property.startswith(w) for w in + component.available_wildcard_properties)): + raise exceptions.NonExistantPropException(''' + Attempting to assign a callback with + the property "{}" but the component + "{}" doesn't have "{}" as a property.\n + Here is a list of the available properties in "{}": + {} + '''.format( + arg.component_property, + arg.component_id, + arg.component_property, + arg.component_id, + component.available_properties).replace( + ' ', '')) + + if (hasattr(arg, 'component_event') and + arg.component_event not in + component.available_events): + raise exceptions.NonExistantEventException(''' + Attempting to assign a callback with + the event "{}" but the component + "{}" doesn't have "{}" as an event.\n + Here is a list of the available events in "{}": + {} + '''.format( + arg.component_event, + arg.component_id, + arg.component_event, + arg.component_id, + component.available_events).replace(' ', '')) + + if state and not events and not inputs: + raise exceptions.MissingEventsException(''' + This callback has {} `State` {} + but no `Input` elements or `Event` elements.\n + Without `Input` or `Event` elements, this callback + will never get called.\n + (Subscribing to input components will cause the + callback to be called whenver their values + change and subscribing to an event will cause the + callback to be called whenever the event is fired.) + '''.format( + len(state), + 'elements' if len(state) > 1 else 'element' + ).replace(' ', '')) + + if '.' in output.component_id: + raise exceptions.IDsCantContainPeriods('''The Output element + `{}` contains a period in its ID. + Periods are not allowed in IDs right now.'''.format( + output.component_id + )) + + callback_id = '{}.{}'.format( + output.component_id, output.component_property) + if callback_id in self.callback_map: + raise exceptions.CantHaveMultipleOutputs(''' + 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).replace(' ', '')) + + def _validate_callback_output(self, output_value, output): + valid = [str, dict, int, float, type(None), Component] + + def _raise_invalid(bad_val, outer_val, bad_type, path, index=None, + toplevel=False): + outer_id = "(id={:s})".format(outer_val.id) \ + if getattr(outer_val, 'id', False) else '' + outer_type = type(outer_val).__name__ + raise exceptions.InvalidCallbackReturnValue(''' + The callback for property `{property:s}` of component `{id: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( + property=output.component_property, + id=output.component_id, + 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, + or 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).replace(' ', '')) + + 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): + 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, + bad_type=type(j).__name__, + path=p, + index=index + ) + + # Children that are not of type Component or + # collections.MutableSequence not returned by traverse + child = getattr(j, 'children', None) + if not isinstance(child, collections.MutableSequence): + if child and not _value_is_valid(child): + _raise_invalid( + bad_val=child, + outer_val=val, + bad_type=type(child).__name__, + 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, collections.MutableSequence): + if child and not _value_is_valid(child): + _raise_invalid( + bad_val=child, + outer_val=val, + bad_type=type(child).__name__, + 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__, + bad_type=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) + + # 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=[], events=[]): + self._validate_callback(output, inputs, state, events) + + callback_id = '{}.{}'.format( + output.component_id, output.component_property + ) + 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 + ], + 'events': [ + {'id': c.component_id, 'event': c.component_event} + for c in events + ] + } + + def wrap_func(func): + @wraps(func) + def add_context(*args, **kwargs): + output_value = func(*args, **kwargs) + response = { + 'response': { + 'props': { + output.component_property: output_value + } + } + } + + try: + return JsonResponse(response) + except TypeError: + self._validate_callback_output(output_value, output) + 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)) + + self.callback_map[callback_id]['callback'] = add_context + + return add_context + + return wrap_func + + def update_component(self, output, inputs, state, **kwargs): + target_id = '{}.{}'.format(output['id'], output['property']) + args = [] + for component_registration in self.callback_map[target_id]['inputs']: + args.append([ + c.get('value', None) for c in inputs if + c['property'] == component_registration['property'] and + c['id'] == component_registration['id'] + ][0]) + + for component_registration in self.callback_map[target_id]['state']: + args.append([ + c.get('value', None) for c in state if + c['property'] == component_registration['property'] and + c['id'] == component_registration['id'] + ][0]) + + return self.callback_map[target_id]['callback'](*args, **kwargs) + + 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() + 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 _walk_assets_directory(self): + ignore_filter = [self.assets_ignore] if self.assets_ignore else None + + def add_resource(p, filepath): + res = {'asset_path': p, 'filepath': filepath} + if self.config.assets_external_path: + res['external_url'] = '{}{}'.format(self.config.assets_external_path, path) + return res + + files = list(get_files(staticfiles_storage, ignore_patterns=ignore_filter, location=self._assets_folder)) + for f in sorted(files): + path = staticfiles_storage.url(f) + full = staticfiles_storage.path(f) + + if f.endswith('js'): + self.scripts.append_script(add_resource(path, full)) + elif f.endswith('css'): + self.css.append_css(add_resource(path, full)) + elif f.endswith('favicon.ico'): + self._favicon = path + + +class BaseDashView(six.with_metaclass(MetaDashView, View)): + dash_template = None + dash_base_url = '/' + dash_name = None + dash_meta_tags = None + dash_external_scripts = None + dash_external_stylesheets = None + dash_assets_folder = None + dash_assets_ignore = None + dash_prefix = '' # For additional special urls + _dashes = {} + + def __init__(self, **kwargs): + dash_base_url = kwargs.pop('dash_base_url', self.dash_base_url) + dash_template = kwargs.pop('dash_template', self.dash_template) + dash_meta_tags = kwargs.pop('dash_meta_tags', self.dash_meta_tags) + dash_external_scripts = kwargs.pop('dash_external_scripts', self.dash_external_scripts) + dash_external_stylesheets = kwargs.pop('dash_external_stylesheets', self.dash_external_stylesheets) + dash_assets_folder = kwargs.pop('dash_assets_folder', self.dash_assets_folder) + dash_assets_ignore = kwargs.pop('dash_assets_ignore', self.dash_assets_ignore) + + super(BaseDashView, self).__init__(**kwargs) + + dash = getattr(self, 'dash', None) + if not isinstance(dash, Dash): + self.dash = Dash() + + if dash_base_url and self.dash.url_base_pathname != dash_base_url: + self.dash.url_base_pathname = dash_base_url # pylint: disable=access-member-before-definition + # pylint: disable=access-member-before-definition + self.dash.config.requests_pathname_prefix = dash_base_url + + if dash_template and self.dash.index_string != dash_template: + self.dash.index_string = dash_template + if dash_meta_tags and self.dash._meta_tags != dash_meta_tags: # pylint: disable=protected-access + # pylint: disable=protected-access, access-member-before-definition + self.dash._meta_tags = dash_meta_tags + # pylint: disable=protected-access + if dash_external_scripts and self.dash._external_scripts != dash_external_scripts: + # pylint: disable=protected-access, access-member-before-definition + self.dash._external_scripts = dash_external_scripts + # pylint: disable=protected-access + if dash_external_stylesheets and self.dash._external_stylesheets != dash_external_stylesheets: + # pylint: disable=protected-access, access-member-before-definition + self.dash._external_stylesheets = dash_external_stylesheets + # pylint: disable=protected-access + if dash_assets_folder and self.dash._assets_folder != dash_assets_folder: + # pylint: disable=protected-access, access-member-before-definition + self.dash._assets_folder = dash_assets_folder + if dash_assets_ignore and self.dash.assets_ignore != dash_assets_ignore: + self.dash.assets_ignore = dash_assets_ignore + + @staticmethod + def _dash_base_url(path, part): + return path[:path.find(part) + 1] + + def _dash_index(self, request, *args, **kwargs): # pylint: disable=unused-argument + return HttpResponse(self.dash.index()) + + def _dash_dependencies(self, request, *args, **kwargs): # pylint: disable=unused-argument + return JsonResponse(self.dash.dependencies()) + + def _dash_layout(self, request, *args, **kwargs): # pylint: disable=unused-argument + # TODO - Set browser cache limit - pass hash into frontend + return JsonResponse(self.dash._layout_value()) # pylint: disable=protected-access + + def _dash_upd_component(self, request, *args, **kwargs): # pylint: disable=unused-argument + body = json.loads(request.body) + + output = body['output'] + inputs = body.get('inputs', []) + state = body.get('state', []) + + return self.dash.update_component(output, inputs, state) + + def _dash_component_suites(self, request, *args, **kwargs): # pylint: disable=unused-argument + ext = kwargs.get('path_in_package_dist', '').split('.')[-1] + mimetype = { + 'js': 'application/JavaScript', + 'css': 'text/css' + }[ext] + + response = HttpResponse(self.dash.serve_component_suites(*args, **kwargs), content_type=mimetype) + response['Cache-Control'] = 'public, max-age={}'.format(self.dash.config.components_cache_max_age) + + return response + + def _dash_routes(self, request, *args, **kwargs): # pylint: disable=unused-argument + return JsonResponse(self.dash.serve_routes(*args, **kwargs)) + + @classmethod + def serve_dash_index(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=request.path) + return view._dash_index(request, *args, **kwargs) # pylint: disable=protected-access + + @classmethod + def serve_dash_dependencies(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=cls._dash_base_url(request.path, '/_dash-dependencies')) + return view._dash_dependencies(request, *args, **kwargs) # pylint: disable=protected-access + + @classmethod + def serve_dash_layout(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=cls._dash_base_url(request.path, '/_dash-layout')) + return view._dash_layout(request, *args, **kwargs) # pylint: disable=protected-access + + @classmethod + @csrf_exempt + def serve_dash_upd_component(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=cls._dash_base_url(request.path, '/_dash-update-component')) + return view._dash_upd_component(request, *args, **kwargs) # pylint: disable=protected-access + + @classmethod + def serve_dash_component_suites(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=cls._dash_base_url(request.path, '/_dash-component-suites')) + return view._dash_component_suites(request, *args, **kwargs) # pylint: disable=protected-access + + @classmethod + def serve_dash_routes(cls, request, dash_name, *args, **kwargs): + view = cls._dashes[dash_name](dash_base_url=cls._dash_base_url(request.path, '/_dash-routes')) + return view._dash_component_suites(request, *args, **kwargs) # pylint: disable=protected-access diff --git a/dash/dependencies.py b/dash/dependencies.py new file mode 100644 index 0000000..0421860 --- /dev/null +++ b/dash/dependencies.py @@ -0,0 +1,26 @@ +# pylint: disable=old-style-class, too-few-public-methods +class Output: + def __init__(self, component_id, component_property): + self.component_id = component_id + self.component_property = component_property + + +# pylint: disable=old-style-class, too-few-public-methods +class Input: + def __init__(self, component_id, component_property): + self.component_id = component_id + self.component_property = component_property + + +# pylint: disable=old-style-class, too-few-public-methods +class State: + def __init__(self, component_id, component_property): + self.component_id = component_id + self.component_property = component_property + + +# pylint: disable=old-style-class, too-few-public-methods +class Event: + def __init__(self, component_id, component_event): + self.component_id = component_id + self.component_event = component_event diff --git a/dash/development/__init__.py b/dash/development/__init__.py new file mode 100644 index 0000000..a8c6749 --- /dev/null +++ b/dash/development/__init__.py @@ -0,0 +1,2 @@ +from . import base_component # noqa:F401 +from . import component_loader # noqa:F401 diff --git a/dash/development/base_component.py b/dash/development/base_component.py new file mode 100644 index 0000000..b3bd6f7 --- /dev/null +++ b/dash/development/base_component.py @@ -0,0 +1,839 @@ +import collections +import copy +import os +import inspect +import keyword + + +def is_number(s): + try: + float(s) + return True + except ValueError: + return False + + +def _check_if_has_indexable_children(item): + if (not hasattr(item, 'children') or + (not isinstance(item.children, Component) and + not isinstance(item.children, collections.MutableSequence))): + + raise KeyError + + +def _explicitize_args(func): + # Python 2 + if hasattr(func, 'func_code'): + varnames = func.func_code.co_varnames + # Python 3 + else: + varnames = func.__code__.co_varnames + + def wrapper(*args, **kwargs): + if '_explicit_args' in kwargs.keys(): + raise Exception('Variable _explicit_args should not be set.') + kwargs['_explicit_args'] = \ + list( + set( + list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] + ) + ) + if 'self' in kwargs['_explicit_args']: + kwargs['_explicit_args'].remove('self') + return func(*args, **kwargs) + + # If Python 3, we can set the function signature to be correct + if hasattr(inspect, 'signature'): + # pylint: disable=no-member + new_sig = inspect.signature(wrapper).replace( + parameters=inspect.signature(func).parameters.values() + ) + wrapper.__signature__ = new_sig + return wrapper + + +class Component(collections.MutableMapping): + class _UNDEFINED(object): + def __repr__(self): + return 'undefined' + + def __str__(self): + return 'undefined' + + UNDEFINED = _UNDEFINED() + + class _REQUIRED(object): + def __repr__(self): + return 'required' + + def __str__(self): + return 'required' + + REQUIRED = _REQUIRED() + + def __init__(self, **kwargs): + # pylint: disable=super-init-not-called + for k, v in list(kwargs.items()): + # pylint: disable=no-member + k_in_propnames = k in self._prop_names + k_in_wildcards = any([k.startswith(w) + for w in + self._valid_wildcard_attributes]) + if not k_in_propnames and not k_in_wildcards: + raise TypeError( + 'Unexpected keyword argument `{}`'.format(k) + + '\nAllowed arguments: {}'.format( + # pylint: disable=no-member + ', '.join(sorted(self._prop_names)) + ) + ) + setattr(self, k, v) + + def to_plotly_json(self): + # Add normal properties + props = { + p: getattr(self, p) + for p in self._prop_names # pylint: disable=no-member + if hasattr(self, p) + } + # Add the wildcard properties data-* and aria-* + props.update({ + k: getattr(self, k) + for k in self.__dict__ + if any(k.startswith(w) for w in + self._valid_wildcard_attributes) # pylint:disable=no-member + }) + as_json = { + 'props': props, + 'type': self._type, # pylint: disable=no-member + 'namespace': self._namespace # pylint: disable=no-member + } + + return as_json + + # pylint: disable=too-many-branches, too-many-return-statements + # pylint: disable=redefined-builtin, inconsistent-return-statements + def _get_set_or_delete(self, id, operation, new_item=None): + _check_if_has_indexable_children(self) + + # pylint: disable=access-member-before-definition, + # pylint: disable=attribute-defined-outside-init + if isinstance(self.children, Component): + if getattr(self.children, 'id', None) is not None: + # Woohoo! It's the item that we're looking for + if self.children.id == id: + if operation == 'get': # pylint: disable=no-else-return + return self.children + elif operation == 'set': + self.children = new_item + return + elif operation == 'delete': + self.children = None + return + + # Recursively dig into its subtree + try: + if operation == 'get': # pylint: disable=no-else-return + return self.children.__getitem__(id) + elif operation == 'set': + self.children.__setitem__(id, new_item) + return + elif operation == 'delete': + self.children.__delitem__(id) + return + except KeyError: + pass + + # if children is like a list + if isinstance(self.children, collections.MutableSequence): + for i, item in enumerate(self.children): + # If the item itself is the one we're looking for + if getattr(item, 'id', None) == id: # pylint: disable=no-else-return + if operation == 'get': # pylint: disable=no-else-return + return item + elif operation == 'set': + self.children[i] = new_item + return + elif operation == 'delete': + del self.children[i] + return + + # Otherwise, recursively dig into that item's subtree + # Make sure it's not like a string + elif isinstance(item, Component): + try: + if operation == 'get': # pylint: disable=no-else-return + return item.__getitem__(id) + elif operation == 'set': + item.__setitem__(id, new_item) + return + elif operation == 'delete': + item.__delitem__(id) + return + except KeyError: + pass + + # The end of our branch + # If we were in a list, then this exception will get caught + raise KeyError(id) + + # Supply ABC methods for a MutableMapping: + # - __getitem__ + # - __setitem__ + # - __delitem__ + # - __iter__ + # - __len__ + + def __getitem__(self, id): # pylint: disable=redefined-builtin + """Recursively find the element with the given ID through the tree + of children. + """ + + # A component's children can be undefined, a string, another component, + # or a list of components. + return self._get_set_or_delete(id, 'get') + + def __setitem__(self, id, item): # pylint: disable=redefined-builtin + """Set an element by its ID.""" + return self._get_set_or_delete(id, 'set', item) + + def __delitem__(self, id): # pylint: disable=redefined-builtin + """Delete items by ID in the tree of children.""" + return self._get_set_or_delete(id, 'delete') + + def traverse(self): + """Yield each item in the tree.""" + for t in self.traverse_with_paths(): + yield t[1] + + 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 is just a component + if isinstance(children, Component): + yield "[*] " + children_string, children + for p, t in children.traverse_with_paths(): + yield "\n".join(["[*] " + children_string, p]), t + + # children is a list of components + elif isinstance(children, collections.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 '' + ) + yield list_path, i + + if isinstance(i, Component): + for p, t in i.traverse_with_paths(): + yield "\n".join([list_path, p]), t + + 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): + """Return the number of items in the tree.""" + # TODO - Should we return the number of items that have IDs + # or just the number of items? + # The number of items is more intuitive but returning the number + # of IDs matches __iter__ better. + length = 0 + if getattr(self, 'children', None) is None: + length = 0 + elif isinstance(self.children, Component): + length = 1 + length += len(self.children) + elif isinstance(self.children, collections.MutableSequence): + for c in self.children: + length += 1 + if isinstance(c, Component): + length += len(c) + else: + # string or number + length = 1 + return length + + +# pylint: disable=unused-argument +def generate_class_string(typename, props, description, namespace): + """ + Dynamically generate class strings to have nicely formatted docstrings, + keyword arguments, and repr + + Inspired by http://jameso.be/2013/08/06/namedtuple.html + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + string + + """ + # TODO _prop_names, _type, _namespace, available_events, + # and available_properties + # can be modified by a Dash JS developer via setattr + # TODO - Tab out the repr for the repr of these components to make it + # look more like a hierarchical tree + # TODO - Include "description" "defaultValue" in the repr and docstring + # + # TODO - Handle "required" + # + # TODO - How to handle user-given `null` values? I want to include + # an expanded docstring like Dropdown(value=None, id=None) + # but by templating in those None values, I have no way of knowing + # whether a property is None because the user explicitly wanted + # it to be `null` or whether that was just the default value. + # The solution might be to deal with default values better although + # not all component authors will supply those. + c = '''class {typename}(Component): + """{docstring}""" + @_explicitize_args + def __init__(self, {default_argtext}): + self._prop_names = {list_of_valid_keys} + self._type = '{typename}' + self._namespace = '{namespace}' + self._valid_wildcard_attributes =\ + {list_of_valid_wildcard_attr_prefixes} + self.available_events = {events} + self.available_properties = {list_of_valid_keys} + self.available_wildcard_properties =\ + {list_of_valid_wildcard_attr_prefixes} + + _explicit_args = kwargs.pop('_explicit_args') + _locals = locals() + _locals.update(kwargs) # For wildcard attrs + args = {{k: _locals[k] for k in _explicit_args if k != 'children'}} + + for k in {required_args}: + if k not in args: + raise TypeError( + 'Required argument `' + k + '` was not specified.') + super({typename}, self).__init__({argtext}) + + def __repr__(self): + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('{typename}(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') + else: + return ( + '{typename}(' + + repr(getattr(self, self._prop_names[0], None)) + ')') +''' + + filtered_props = reorder_props(filter_props(props)) + # pylint: disable=unused-variable + list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) + # pylint: disable=unused-variable + list_of_valid_keys = repr(list(map(str, filtered_props.keys()))) + # pylint: disable=unused-variable + docstring = create_docstring( + component_name=typename, + props=filtered_props, + events=parse_events(props), + description=description) + + # pylint: disable=unused-variable + events = '[' + ', '.join(parse_events(props)) + ']' + prop_keys = list(props.keys()) + if 'children' in props: + prop_keys.remove('children') + default_argtext = "children=None, " + # pylint: disable=unused-variable + argtext = 'children=children, **args' + else: + default_argtext = "" + argtext = '**args' + default_argtext += ", ".join( + [('{:s}=Component.REQUIRED'.format(p) + if props[p]['required'] else + '{:s}=Component.UNDEFINED'.format(p)) + for p in prop_keys + if not p.endswith("-*") and + p not in keyword.kwlist and + p not in ['dashEvents', 'fireEvent', 'setProps']] + ['**kwargs'] + ) + + required_args = required_props(props) + return c.format(**locals()) + + +# pylint: disable=unused-argument +def generate_class_file(typename, props, description, namespace): + """ + Generate a python class file (.py) given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import " + \ + "Component, _explicitize_args\n\n\n" + class_string = generate_class_string( + typename, + props, + description, + namespace + ) + file_name = "{:s}.py".format(typename) + + file_path = os.path.join(namespace, file_name) + with open(file_path, 'w') as f: + f.write(import_string) + f.write(class_string) + + +# pylint: disable=unused-argument +def generate_class(typename, props, description, namespace): + """ + Generate a python class object given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + string = generate_class_string(typename, props, description, namespace) + scope = {'Component': Component, '_explicitize_args': _explicitize_args} + # pylint: disable=exec-used + exec(string, scope) + result = scope[typename] + return result + + +def required_props(props): + """ + Pull names of required props from the props object + + Parameters + ---------- + props: dict + + Returns + ------- + list + List of prop names (str) that are required for the Component + """ + return [prop_name for prop_name, prop in list(props.items()) + if prop['required']] + + +def create_docstring(component_name, props, events, description): + """ + Create the Dash component docstring + + Parameters + ---------- + component_name: str + Component name + props: dict + Dictionary with {propName: propMetadata} structure + events: list + List of Dash events + description: str + Component description + + Returns + ------- + str + Dash component docstring + """ + # Ensure props are ordered with children first + props = reorder_props(props=props) + + return ( + """A {name} component.\n{description} + +Keyword arguments:\n{args} + +Available events: {events}""" + ).format( + name=component_name, + description=description, + args='\n'.join( + create_prop_docstring( + prop_name=p, + type_object=prop['type'] if 'type' in prop + else prop['flowType'], + required=prop['required'], + description=prop['description'], + indent_num=0, + is_flow_type='flowType' in prop and 'type' not in prop) + for p, prop in list(filter_props(props).items())), + events=', '.join(events)) + + +def parse_events(props): + """ + Pull out the dashEvents from the Component props + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + list + List of Dash event strings + """ + if 'dashEvents' in props and props['dashEvents']['type']['name'] == 'enum': + events = [v['value'] for v in props['dashEvents']['type']['value']] + else: + events = [] + + return events + + +def parse_wildcards(props): + """ + Pull out the wildcard attributes from the Component props + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + list + List of Dash valid wildcard prefixes + """ + list_of_valid_wildcard_attr_prefixes = [] + for wildcard_attr in ["data-*", "aria-*"]: + if wildcard_attr in props.keys(): + list_of_valid_wildcard_attr_prefixes.append(wildcard_attr[:-1]) + return list_of_valid_wildcard_attr_prefixes + + +def reorder_props(props): + """ + If "children" is in props, then move it to the + front to respect dash convention + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + dict + Dictionary with {propName: propMetadata} structure + """ + if 'children' in props: + props = collections.OrderedDict( + [('children', props.pop('children'),)] + + list(zip(list(props.keys()), list(props.values())))) + + return props + + +def filter_props(props): + """ + Filter props from the Component arguments to exclude: + - Those without a "type" or a "flowType" field + - Those with arg.type.name in {'func', 'symbol', 'instanceOf'} + - dashEvents as a name + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + dict + Filtered dictionary with {propName: propMetadata} structure + + Examples + -------- + ```python + prop_args = { + 'prop1': { + 'type': {'name': 'bool'}, + 'required': False, + 'description': 'A description', + 'flowType': {}, + 'defaultValue': {'value': 'false', 'computed': False}, + }, + 'prop2': {'description': 'A prop without a type'}, + 'prop3': { + 'type': {'name': 'func'}, + 'description': 'A function prop', + }, + } + # filtered_prop_args is now + # { + # 'prop1': { + # 'type': {'name': 'bool'}, + # 'required': False, + # 'description': 'A description', + # 'flowType': {}, + # 'defaultValue': {'value': 'false', 'computed': False}, + # }, + # } + filtered_prop_args = filter_props(prop_args) + ``` + """ + filtered_props = copy.deepcopy(props) + + for arg_name, arg in list(filtered_props.items()): + if 'type' not in arg and 'flowType' not in arg: + filtered_props.pop(arg_name) + continue + + # Filter out functions and instances -- + # these cannot be passed from Python + if 'type' in arg: # These come from PropTypes + arg_type = arg['type']['name'] + if arg_type in {'func', 'symbol', 'instanceOf'}: + filtered_props.pop(arg_name) + elif 'flowType' in arg: # These come from Flow & handled differently + arg_type_name = arg['flowType']['name'] + if arg_type_name == 'signature': + # This does the same as the PropTypes filter above, but "func" + # is under "type" if "name" is "signature" vs just in "name" + if 'type' not in arg['flowType'] \ + or arg['flowType']['type'] != 'object': + filtered_props.pop(arg_name) + else: + raise ValueError + + # dashEvents are a special oneOf property that is used for subscribing + # to events but it's never set as a property + if arg_name in ['dashEvents']: + filtered_props.pop(arg_name) + return filtered_props + + +# pylint: disable=too-many-arguments +def create_prop_docstring(prop_name, type_object, required, description, + indent_num, is_flow_type=False): + """ + Create the Dash component prop docstring + + Parameters + ---------- + prop_name: str + Name of the Dash component prop + type_object: dict + react-docgen-generated prop type dictionary + required: bool + Component is required? + description: str + Dash component description + indent_num: int + Number of indents to use for the context block + (creates 2 spaces for every indent) + is_flow_type: bool + Does the prop use Flow types? Otherwise, uses PropTypes + + Returns + ------- + str + Dash component prop docstring + """ + py_type_name = js_to_py_type( + type_object=type_object, + is_flow_type=is_flow_type, + indent_num=indent_num + 1) + + indent_spacing = ' ' * indent_num + if '\n' in py_type_name: + return '{indent_spacing}- {name} ({is_required}): {description}. ' \ + '{name} has the following type: {type}'.format( + indent_spacing=indent_spacing, + name=prop_name, + type=py_type_name, + description=description, + is_required='required' if required else 'optional') + return '{indent_spacing}- {name} ({type}' \ + '{is_required}){description}'.format( + indent_spacing=indent_spacing, + name=prop_name, + type='{}; '.format(py_type_name) if py_type_name else '', + description=( + ': {}'.format(description) if description != '' else '' + ), + is_required='required' if required else 'optional') + + +def map_js_to_py_types_prop_types(type_object): + """Mapping from the PropTypes js type object to the Python type""" + return dict( + array=lambda: 'list', + bool=lambda: 'boolean', + number=lambda: 'number', + string=lambda: 'string', + object=lambda: 'dict', + any=lambda: 'boolean | number | string | dict | list', + element=lambda: 'dash component', + node=lambda: 'a list of or a singular dash ' + 'component, string or number', + + # React's PropTypes.oneOf + enum=lambda: 'a value equal to: {}'.format( + ', '.join( + '{}'.format(str(t['value'])) + for t in type_object['value'])), + + # React's PropTypes.oneOfType + union=lambda: '{}'.format( + ' | '.join( + '{}'.format(js_to_py_type(subType)) + for subType in type_object['value'] + if js_to_py_type(subType) != '')), + + # React's PropTypes.arrayOf + arrayOf=lambda: 'list'.format( # pylint: disable=too-many-format-args + ' of {}s'.format( + js_to_py_type(type_object['value'])) + if js_to_py_type(type_object['value']) != '' + else ''), + + # React's PropTypes.objectOf + objectOf=lambda: ( + 'dict with strings as keys and values of type {}' + ).format( + js_to_py_type(type_object['value'])), + + # React's PropTypes.shape + shape=lambda: 'dict containing keys {}.\n{}'.format( + ', '.join( + "'{}'".format(t) + for t in list(type_object['value'].keys())), + 'Those keys have the following types: \n{}'.format( + '\n'.join(create_prop_docstring( + prop_name=prop_name, + type_object=prop, + required=prop['required'], + description=prop.get('description', ''), + indent_num=1) + for prop_name, prop in + list(type_object['value'].items())))), + ) + + +def map_js_to_py_types_flow_types(type_object): + """Mapping from the Flow js types to the Python type""" + return dict( + array=lambda: 'list', + boolean=lambda: 'boolean', + number=lambda: 'number', + string=lambda: 'string', + Object=lambda: 'dict', + any=lambda: 'bool | number | str | dict | list', + Element=lambda: 'dash component', + Node=lambda: 'a list of or a singular dash ' + 'component, string or number', + + # React's PropTypes.oneOfType + union=lambda: '{}'.format( + ' | '.join( + '{}'.format(js_to_py_type(subType)) + for subType in type_object['elements'] + if js_to_py_type(subType) != '')), + + # Flow's Array type + Array=lambda: 'list{}'.format( + ' of {}s'.format( + js_to_py_type(type_object['elements'][0])) + if js_to_py_type(type_object['elements'][0]) != '' + else ''), + + # React's PropTypes.shape + signature=lambda indent_num: 'dict containing keys {}.\n{}'.format( + ', '.join("'{}'".format(d['key']) + for d in type_object['signature']['properties']), + '{}Those keys have the following types: \n{}'.format( + ' ' * indent_num, + '\n'.join( + create_prop_docstring( + prop_name=prop['key'], + type_object=prop['value'], + required=prop['value']['required'], + description=prop['value'].get('description', ''), + indent_num=indent_num, + is_flow_type=True) + for prop in type_object['signature']['properties']))), + ) + + +def js_to_py_type(type_object, is_flow_type=False, indent_num=0): + """ + Convert JS types to Python types for the component definition + + Parameters + ---------- + type_object: dict + react-docgen-generated prop type dictionary + is_flow_type: bool + Does the prop use Flow types? Otherwise, uses PropTypes + indent_num: int + Number of indents to use for the docstring for the prop + + Returns + ------- + str + Python type string + """ + js_type_name = type_object['name'] + js_to_py_types = map_js_to_py_types_flow_types(type_object=type_object) \ + if is_flow_type \ + else map_js_to_py_types_prop_types(type_object=type_object) + + # pylint: disable=no-else-return + if 'computed' in type_object and type_object['computed'] \ + or type_object.get('type', '') == 'function': + return '' + elif js_type_name in js_to_py_types: + if js_type_name == 'signature': # This is a Flow object w/ signature + return js_to_py_types[js_type_name](indent_num) + # All other types + return js_to_py_types[js_type_name]() + return '' diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py new file mode 100644 index 0000000..74d2e55 --- /dev/null +++ b/dash/development/component_loader.py @@ -0,0 +1,109 @@ +import collections +import json +import os +from .base_component import generate_class +from .base_component import generate_class_file + + +def _get_metadata(metadata_path): + # Start processing + with open(metadata_path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + return data + + +def load_components(metadata_path, + namespace='default_namespace'): + """Load React component metadata into a format Dash can parse. + + Usage: load_components('../../component-suites/lib/metadata.json') + + Keyword arguments: + metadata_path -- a path to a JSON file created by + [`react-docgen`](https://github.com/reactjs/react-docgen). + + Returns: + components -- a list of component objects with keys + `type`, `valid_kwargs`, and `setup`. + """ + + components = [] + + data = _get_metadata(metadata_path) + + # Iterate over each property name (which is a path to the component) + for componentPath in data: + componentData = data[componentPath] + + # Extract component name from path + # e.g. src/components/MyControl.react.js + # TODO Make more robust - some folks will write .jsx and others + # will be on windows. Unfortunately react-docgen doesn't include + # the name of the component atm. + name = componentPath.split('/').pop().split('.')[0] + component = generate_class( + name, + componentData['props'], + componentData['description'], + namespace + ) + + components.append(component) + + return components + + +def generate_classes(namespace, metadata_path='lib/metadata.json'): + """Load React component metadata into a format Dash can parse, + then create python class files. + + Usage: generate_classes() + + Keyword arguments: + namespace -- name of the generated python package (also output dir) + + metadata_path -- a path to a JSON file created by + [`react-docgen`](https://github.com/reactjs/react-docgen). + + Returns: + """ + + data = _get_metadata(metadata_path) + imports_path = os.path.join(namespace, '_imports_.py') + + # Make sure the file doesn't exist, as we use append write + if os.path.exists(imports_path): + os.remove(imports_path) + + # Iterate over each property name (which is a path to the component) + for componentPath in data: + componentData = data[componentPath] + + # Extract component name from path + # e.g. src/components/MyControl.react.js + # TODO Make more robust - some folks will write .jsx and others + # will be on windows. Unfortunately react-docgen doesn't include + # the name of the component atm. + name = componentPath.split('/').pop().split('.')[0] + generate_class_file( + name, + componentData['props'], + componentData['description'], + namespace + ) + + # Add an import statement for this component + with open(imports_path, 'a') as f: + f.write('from .{0:s} import {0:s}\n'.format(name)) + + # Add the __all__ value so we can import * from _imports_ + all_imports = [p.split('/').pop().split('.')[0] for p in data] + with open(imports_path, 'a') as f: + array_string = '[\n' + for a in all_imports: + array_string += ' "{:s}",\n'.format(a) + array_string += ']\n' + f.write('\n\n__all__ = {:s}'.format(array_string)) diff --git a/dash/exceptions.py b/dash/exceptions.py new file mode 100644 index 0000000..5ec2779 --- /dev/null +++ b/dash/exceptions.py @@ -0,0 +1,66 @@ +class DashException(Exception): + pass + + +class NoLayoutException(DashException): + pass + + +class CallbackException(DashException): + pass + + +class NonExistantIdException(CallbackException): + pass + + +class NonExistantPropException(CallbackException): + pass + + +class NonExistantEventException(CallbackException): + pass + + +class UndefinedLayoutException(CallbackException): + pass + + +class IncorrectTypeException(CallbackException): + pass + + +class MissingEventsException(CallbackException): + pass + + +class LayoutIsNotDefined(CallbackException): + pass + + +class IDsCantContainPeriods(CallbackException): + pass + + +class CantHaveMultipleOutputs(CallbackException): + pass + + +class PreventUpdate(CallbackException): + pass + + +class DuplicateIdError(DashException): + pass + + +class InvalidCallbackReturnValue(CallbackException): + pass + + +class InvalidConfig(DashException): + pass + + +class InvalidResourceError(DashException): + pass diff --git a/dash/resources.py b/dash/resources.py new file mode 100644 index 0000000..aa1ce87 --- /dev/null +++ b/dash/resources.py @@ -0,0 +1,143 @@ +from copy import copy +import json +import warnings +import os + +from .development.base_component import Component + + +# pylint: disable=old-style-class +class Resources: + def __init__(self, resource_name, layout): + self._resources = [] + self.resource_name = resource_name + self.layout = layout + + def append_resource(self, resource): + self._resources.append(resource) + + def _filter_resources(self, all_resources, dev_bundles=False): + filtered_resources = [] + for s in all_resources: + filtered_resource = {} + if 'namespace' in s: + filtered_resource['namespace'] = s['namespace'] + if 'external_url' in s and not self.config.serve_locally: + filtered_resource['external_url'] = s['external_url'] + elif 'dev_package_path' in s and dev_bundles: + filtered_resource['relative_package_path'] = ( + s['dev_package_path'] + ) + elif 'relative_package_path' in s: + filtered_resource['relative_package_path'] = ( + s['relative_package_path'] + ) + elif 'absolute_path' in s: + filtered_resource['absolute_path'] = s['absolute_path'] + elif 'asset_path' in s: + info = os.stat(s['filepath']) + filtered_resource['asset_path'] = s['asset_path'] + filtered_resource['ts'] = info.st_mtime + elif self.config.serve_locally: + warnings.warn( + 'A local version of {} is not available'.format( + s['external_url'] + ) + ) + continue + else: + raise Exception( + '{} does not have a ' + 'relative_package_path, absolute_path, or an ' + 'external_url.'.format( + json.dumps(filtered_resource) + ) + ) + + filtered_resources.append(filtered_resource) + + return filtered_resources + + def get_all_resources(self, dev_bundles=False): + all_resources = [] + if self.config.infer_from_layout: + all_resources = ( + self.get_inferred_resources() + self._resources + ) + else: + all_resources = self._resources + + return self._filter_resources(all_resources, dev_bundles) + + def get_inferred_resources(self): + namespaces = [] + resources = [] + layout = self.layout + + def extract_resource_from_component(component): + # pylint: disable=protected-access + if (isinstance(component, Component) and + component._namespace not in namespaces): + + namespaces.append(component._namespace) + + if hasattr(component, self.resource_name): + + component_resources = copy( + getattr(component, self.resource_name) + ) + for r in component_resources: + r['namespace'] = component._namespace + resources.extend(component_resources) + + extract_resource_from_component(layout) + for t in layout.traverse(): + extract_resource_from_component(t) + return resources + + +class Css: + # pylint: disable=old-style-class + def __init__(self, layout=None): + self._resources = Resources('_css_dist', layout) + self._resources.config = self.config + + def _update_layout(self, layout): + self._resources.layout = layout + + def append_css(self, stylesheet): + self._resources.append_resource(stylesheet) + + def get_all_css(self): + return self._resources.get_all_resources() + + def get_inferred_css_dist(self): + return self._resources.get_inferred_resources() + + # pylint: disable=old-style-class, no-init, too-few-public-methods + class config: + infer_from_layout = True + serve_locally = False + + +class Scripts: # pylint: disable=old-style-class + def __init__(self, layout=None): + self._resources = Resources('_js_dist', layout) + self._resources.config = self.config + + def _update_layout(self, layout): + self._resources.layout = layout + + def append_script(self, script): + self._resources.append_resource(script) + + def get_all_scripts(self, dev_bundles=False): + return self._resources.get_all_resources(dev_bundles) + + def get_inferred_scripts(self): + return self._resources.get_inferred_resources() + + # pylint: disable=old-style-class, no-init, too-few-public-methods + class config: + infer_from_layout = True + serve_locally = False diff --git a/dash/urls.py b/dash/urls.py new file mode 100644 index 0000000..a7a4090 --- /dev/null +++ b/dash/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import include, url + +from .dash import BaseDashView + + +urlpatterns = [ + url(r'^(?P[\-\w_0-9]+)/', include([ + url(r'^$', BaseDashView.serve_dash_index), + url(r'^(?P[\-\w_.@0-9]+)/$', BaseDashView.serve_dash_index), + url(r'^_dash-dependencies', BaseDashView.serve_dash_dependencies), + url(r'^_dash-layout', BaseDashView.serve_dash_layout), + url(r'^_dash-update-component', BaseDashView.serve_dash_upd_component), + url(r'^_dash-component-suites/(?P[\-\w_@0-9]+)/' + r'(?P[\-\w_.@0-9]+)', + BaseDashView.serve_dash_component_suites), + url(r'^_dash-routes', BaseDashView.serve_dash_routes), + ])) +] diff --git a/dash/version.py b/dash/version.py new file mode 100644 index 0000000..0404d81 --- /dev/null +++ b/dash/version.py @@ -0,0 +1 @@ +__version__ = '0.3.0' diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..5d05176 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,12 @@ +;dash_core_components>=0.30.2 +;dash_html_components>=0.13.2 +;dash_renderer>=0.14.1 +;Django>=1.9,<2.2 +;dash_flow_example==0.0.3 +;dash-dangerously-set-inner-html +selenium>=3.14.1 +tox>=3.4.0 +plotly>=2.0.8 +pytest-django>=3.4.3 +flake8>=3.5.0 +pylint==2.1.1 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8b24811 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = project.settings diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e011cba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +plotly>=2.0.8 +dash_core_components>=0.30.2 +dash_html_components>=0.13.2 +dash-renderer>=0.14.1 +Django>=1.9,<2.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..15e24a6 --- /dev/null +++ b/setup.py @@ -0,0 +1,51 @@ +import io +from setuptools import setup, find_packages + +main_ns = {} +exec(open('dash/version.py').read(), main_ns) # pylint: disable=exec-used + +setup( + name='dj-plotly-dash', + version=main_ns['__version__'], + author='Sergei Pikhovkin', + author_email='s@pikhovkin.ru', + packages=find_packages(exclude=['tests*']), + license='MIT', + description=('A Python framework for building reactive web-apps. ' + 'Developed by Plotly.'), + long_description=io.open('README.md', encoding='utf-8').read(), + install_requires=[ + 'Django>=1.9,<2.2', + 'plotly>=2.0.8', + 'dash_renderer>=0.14.1', + ], + url='https://github.com/pikhovkin/dj-plotly-dash', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Framework :: Django :: 1.9', + 'Framework :: Django :: 1.10', + 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: Financial and Insurance Industry', + 'Intended Audience :: Healthcare Industry', + 'Intended Audience :: Manufacturing', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Database :: Front-Ends', + 'Topic :: Office/Business :: Financial :: Spreadsheet', + 'Topic :: Scientific/Engineering :: Visualization', + 'Topic :: Software Development :: Libraries :: Application Frameworks', + 'Topic :: Software Development :: Widget Sets' + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/development/TestReactComponent.react.js b/tests/development/TestReactComponent.react.js new file mode 100644 index 0000000..5c45fed --- /dev/null +++ b/tests/development/TestReactComponent.react.js @@ -0,0 +1,114 @@ +import React from 'react'; +// A react component with all of the available proptypes to run tests over + +/** + * This is a description of the component. + * It's multiple lines long. + */ +class ReactComponent extends Component { + render() { + return ''; + } +} + +ReactComponent.propTypes = { + /** + * Description of optionalArray + */ + optionalArray: React.PropTypes.array, + optionalBool: React.PropTypes.bool, + optionalFunc: React.PropTypes.func, + optionalNumber: React.PropTypes.number, + optionalObject: React.PropTypes.object, + optionalString: React.PropTypes.string, + optionalSymbol: React.PropTypes.symbol, + + // Anything that can be rendered: numbers, strings, elements or an array + // (or fragment) containing these types. + optionalNode: React.PropTypes.node, + + // A React element. + optionalElement: React.PropTypes.element, + + // You can also declare that a prop is an instance of a class. This uses + // JS's instanceof operator. + optionalMessage: React.PropTypes.instanceOf(Message), + + // You can ensure that your prop is limited to specific values by treating + // it as an enum. + optionalEnum: React.PropTypes.oneOf(['News', 'Photos']), + + // An object that could be one of many types + optionalUnion: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number, + React.PropTypes.instanceOf(Message) + ]), + + // An array of a certain type + optionalArrayOf: React.PropTypes.arrayOf(React.PropTypes.number), + + // An object with property values of a certain type + optionalObjectOf: React.PropTypes.objectOf(React.PropTypes.number), + + // An object taking on a particular shape + optionalObjectWithShapeAndNestedDescription: React.PropTypes.shape({ + color: React.PropTypes.string, + fontSize: React.PropTypes.number, + /** + * Figure is a plotly graph object + */ + figure: React.PropTypes.shape({ + /** + * data is a collection of traces + */ + data: React.PropTypes.arrayOf(React.PropTypes.object), + /** + * layout describes the rest of the figure + */ + layout: React.PropTypes.object + }) + }), + + // A value of any data type + optionalAny: React.PropTypes.any, + + customProp: function(props, propName, componentName) { + if (!/matchme/.test(props[propName])) { + return new Error( + 'Invalid prop `' + propName + '` supplied to' + + ' `' + componentName + '`. Validation failed.' + ); + } + }, + + customArrayProp: React.PropTypes.arrayOf(function(propValue, key, componentName, location, propFullName) { + if (!/matchme/.test(propValue[key])) { + return new Error( + 'Invalid prop `' + propFullName + '` supplied to' + + ' `' + componentName + '`. Validation failed.' + ); + } + }), + + // special dash events + + children: React.PropTypes.node, + + id: React.PropTypes.string, + + + // dashEvents is a special prop that is used to events validation + dashEvents: React.PropTypes.oneOf([ + 'restyle', + 'relayout', + 'click' + ]) +}; + +ReactComponent.defaultProps = { + optionalNumber: 42, + optionalString: 'hello world' +}; + +export default ReactComponent; diff --git a/tests/development/TestReactComponentRequired.react.js b/tests/development/TestReactComponentRequired.react.js new file mode 100644 index 0000000..a08b0f0 --- /dev/null +++ b/tests/development/TestReactComponentRequired.react.js @@ -0,0 +1,19 @@ +import React from 'react'; +// A react component with all of the available proptypes to run tests over + +/** + * This is a description of the component. + * It's multiple lines long. + */ +class ReactComponent extends Component { + render() { + return ''; + } +} + +ReactComponent.propTypes = { + children: React.PropTypes.node, + id: React.PropTypes.string.isRequired, +}; + +export default ReactComponent; diff --git a/tests/development/__init__.py b/tests/development/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/development/flow_metadata_test.json b/tests/development/flow_metadata_test.json new file mode 100644 index 0000000..bc93445 --- /dev/null +++ b/tests/development/flow_metadata_test.json @@ -0,0 +1,368 @@ +{ + "description": "This is a test description of the component.\nIt's multiple lines long.", + "methods": [], + "props": { + "requiredString": { + "required": true, + "description": "A required string", + "flowType": { + "name": "string" + } + }, + "optionalString": { + "required": false, + "description": "A string that isn't required.", + "flowType": { + "name": "string" + }, + "defaultValue": { + "value": "''", + "computed": false + } + }, + "optionalBoolean": { + "required": false, + "description": "A boolean test", + "flowType": { + "name": "boolean" + }, + "defaultValue": { + "value": "false", + "computed": false + } + }, + "optionalFunc": { + "required": false, + "description": "Dash callback to update props on the server", + "flowType": { + "name": "signature", + "type": "function", + "raw": "(props: { modal?: boolean, open?: boolean }) => void", + "signature": { + "arguments": [ + { + "name": "props", + "type": { + "name": "signature", + "type": "object", + "raw": "{ modal?: boolean, open?: boolean }", + "signature": { + "properties": [ + { + "key": "modal", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "open", + "value": { + "name": "boolean", + "required": false + } + } + ] + } + } + } + ], + "return": { + "name": "void" + } + } + }, + "defaultValue": { + "value": "() => {}", + "computed": false + } + }, + "optionalNode": { + "required": false, + "description": "A node test", + "flowType": { + "name": "Node" + }, + "defaultValue": { + "value": "null", + "computed": false + } + }, + "optionalArray": { + "required": false, + "description": "An array test with a particularly \nlong description that covers several lines. It includes the newline character \nand should span 3 lines in total.", + "flowType": { + "name": "Array", + "elements": [ + { + "name": "signature", + "type": "object", + "raw": "{\n checked?: boolean,\n children?: Node,\n customData: any,\n disabled?: boolean,\n label?: string,\n primaryText: string,\n secondaryText?: string,\n style?: Object,\n value: any,\n}", + "signature": { + "properties": [ + { + "key": "checked", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "children", + "value": { + "name": "Node", + "required": false + } + }, + { + "key": "customData", + "value": { + "name": "any", + "required": true + } + }, + { + "key": "disabled", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "primaryText", + "value": { + "name": "string", + "required": true + } + }, + { + "key": "secondaryText", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "style", + "value": { + "name": "Object", + "required": false + } + }, + { + "key": "value", + "value": { + "name": "any", + "required": true + } + } + ] + } + } + ], + "raw": "Array" + }, + "defaultValue": { + "value": "[]", + "computed": false + } + }, + "requiredUnion": { + "required": true, + "description": "", + "flowType": { + "name": "union", + "raw": "string | number", + "elements": [ + { + "name": "string" + }, + { + "name": "number" + } + ] + } + }, + "optionalSignature(shape)": { + "flowType": { + "name": "signature", + "type": "object", + "raw": "{\n checked?: boolean,\n children?: Node,\n customData: any,\n disabled?: boolean,\n label?: string,\n primaryText: string,\n secondaryText?: string,\n style?: Object,\n value: any,\n}", + "signature": { + "properties": [ + { + "key": "checked", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "children", + "value": { + "name": "Node", + "required": false + } + }, + { + "key": "customData", + "value": { + "name": "any", + "required": true, + "description": "A test description" + } + }, + { + "key": "disabled", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "primaryText", + "value": { + "name": "string", + "required": true, + "description": "Another test description" + } + }, + { + "key": "secondaryText", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "style", + "value": { + "name": "Object", + "required": false + } + }, + { + "key": "value", + "value": { + "name": "any", + "required": true + } + } + ] + } + }, + "required": false, + "description": "This is a test of an object's shape" + }, + "requiredNested": { + "flowType": { + "name": "signature", + "type": "object", + "raw": "{\n customData: SD_MENU_ITEM,\n value: any,\n}", + "signature": { + "properties": [ + { + "key": "customData", + "value": { + "name": "signature", + "type": "object", + "raw": "{\n checked?: boolean,\n children?: Node,\n customData: any,\n disabled?: boolean,\n label?: string,\n primaryText: string,\n secondaryText?: string,\n style?: Object,\n value: any,\n}", + "signature": { + "properties": [ + { + "key": "checked", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "children", + "value": { + "name": "Node", + "required": false + } + }, + { + "key": "customData", + "value": { + "name": "any", + "required": true + } + }, + { + "key": "disabled", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "primaryText", + "value": { + "name": "string", + "required": true + } + }, + { + "key": "secondaryText", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "style", + "value": { + "name": "Object", + "required": false + } + }, + { + "key": "value", + "value": { + "name": "any", + "required": true + } + } + ] + }, + "required": true + } + }, + { + "key": "value", + "value": { + "name": "any", + "required": true + } + } + ] + } + }, + "required": true, + "description": "" + } + } +} \ No newline at end of file diff --git a/tests/development/metadata_required_test.json b/tests/development/metadata_required_test.json new file mode 100644 index 0000000..9b2caa6 --- /dev/null +++ b/tests/development/metadata_required_test.json @@ -0,0 +1,20 @@ +{ + "description": "This is a description of the component.\nIt's multiple lines long.", + "methods": [], + "props": { + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "id": { + "type": { + "name": "string" + }, + "required": true, + "description": "" + } + } +} diff --git a/tests/development/metadata_test.json b/tests/development/metadata_test.json new file mode 100644 index 0000000..1da85ba --- /dev/null +++ b/tests/development/metadata_test.json @@ -0,0 +1,260 @@ +{ + "description": "This is a description of the component.\nIt's multiple lines long.", + "methods": [], + "props": { + "optionalArray": { + "type": { + "name": "array" + }, + "required": false, + "description": "Description of optionalArray" + }, + "optionalBool": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, + "optionalFunc": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "optionalNumber": { + "type": { + "name": "number" + }, + "required": false, + "description": "", + "defaultValue": { + "value": "42", + "computed": false + } + }, + "optionalObject": { + "type": { + "name": "object" + }, + "required": false, + "description": "" + }, + "optionalString": { + "type": { + "name": "string" + }, + "required": false, + "description": "", + "defaultValue": { + "value": "'hello world'", + "computed": false + } + }, + "optionalSymbol": { + "type": { + "name": "symbol" + }, + "required": false, + "description": "" + }, + "optionalNode": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "optionalElement": { + "type": { + "name": "element" + }, + "required": false, + "description": "" + }, + "optionalMessage": { + "type": { + "name": "instanceOf", + "value": "Message" + }, + "required": false, + "description": "" + }, + "optionalEnum": { + "type": { + "name": "enum", + "value": [ + { + "value": "'News'", + "computed": false + }, + { + "value": "'Photos'", + "computed": false + } + ] + }, + "required": false, + "description": "" + }, + "optionalUnion": { + "type": { + "name": "union", + "value": [ + { + "name": "string" + }, + { + "name": "number" + }, + { + "name": "instanceOf", + "value": "Message" + } + ] + }, + "required": false, + "description": "" + }, + "optionalArrayOf": { + "type": { + "name": "arrayOf", + "value": { + "name": "number" + } + }, + "required": false, + "description": "" + }, + "optionalObjectOf": { + "type": { + "name": "objectOf", + "value": { + "name": "number" + } + }, + "required": false, + "description": "" + }, + "optionalObjectWithShapeAndNestedDescription": { + "type": { + "name": "shape", + "value": { + "color": { + "name": "string", + "required": false + }, + "fontSize": { + "name": "number", + "required": false + }, + "figure": { + "name": "shape", + "value": { + "data": { + "name": "arrayOf", + "value": { + "name": "object" + }, + "description": "data is a collection of traces", + "required": false + }, + "layout": { + "name": "object", + "description": "layout describes the rest of the figure", + "required": false + } + }, + "description": "Figure is a plotly graph object", + "required": false + } + } + }, + "required": false, + "description": "" + }, + "optionalAny": { + "type": { + "name": "any" + }, + "required": false, + "description": "" + }, + "customProp": { + "type": { + "name": "custom", + "raw": "function(props, propName, componentName) {\n if (!/matchme/.test(props[propName])) {\n return new Error(\n 'Invalid prop `' + propName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" + }, + "required": false, + "description": "" + }, + "customArrayProp": { + "type": { + "name": "arrayOf", + "value": { + "name": "custom", + "raw": "function(propValue, key, componentName, location, propFullName) {\n if (!/matchme/.test(propValue[key])) {\n return new Error(\n 'Invalid prop `' + propFullName + '` supplied to' +\n ' `' + componentName + '`. Validation failed.'\n );\n }\n}" + } + }, + "required": false, + "description": "" + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "data-*": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "aria-*": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "in": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "id": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "dashEvents": { + "type": { + "name": "enum", + "value": [ + { + "value": "'restyle'", + "computed": false + }, + { + "value": "'relayout'", + "computed": false + }, + { + "value": "'click'", + "computed": false + } + ] + }, + "required": false, + "description": "" + } + } +} diff --git a/tests/development/metadata_test.py b/tests/development/metadata_test.py new file mode 100644 index 0000000..1074ff0 --- /dev/null +++ b/tests/development/metadata_test.py @@ -0,0 +1,83 @@ +# AUTO GENERATED FILE - DO NOT EDIT + +from dash.development.base_component import Component, _explicitize_args + + +class Table(Component): + """A Table component. +This is a description of the component. +It's multiple lines long. + +Keyword arguments: +- children (a list of or a singular dash component, string or number; optional) +- optionalArray (list; optional): Description of optionalArray +- optionalBool (boolean; optional) +- optionalNumber (number; optional) +- optionalObject (dict; optional) +- optionalString (string; optional) +- optionalNode (a list of or a singular dash component, string or number; optional) +- optionalElement (dash component; optional) +- optionalEnum (a value equal to: 'News', 'Photos'; optional) +- optionalUnion (string | number; optional) +- optionalArrayOf (list; optional) +- optionalObjectOf (dict with strings as keys and values of type number; optional) +- optionalObjectWithShapeAndNestedDescription (optional): . optionalObjectWithShapeAndNestedDescription has the following type: dict containing keys 'color', 'fontSize', 'figure'. +Those keys have the following types: + - color (string; optional) + - fontSize (number; optional) + - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'. +Those keys have the following types: + - data (list; optional): data is a collection of traces + - layout (dict; optional): layout describes the rest of the figure +- optionalAny (boolean | number | string | dict | list; optional) +- customProp (optional) +- customArrayProp (list; optional) +- data-* (string; optional) +- aria-* (string; optional) +- in (string; optional) +- id (string; optional) + +Available events: 'restyle', 'relayout', 'click'""" + @_explicitize_args + def __init__(self, children=None, optionalArray=Component.UNDEFINED, optionalBool=Component.UNDEFINED, optionalFunc=Component.UNDEFINED, optionalNumber=Component.UNDEFINED, optionalObject=Component.UNDEFINED, optionalString=Component.UNDEFINED, optionalSymbol=Component.UNDEFINED, optionalNode=Component.UNDEFINED, optionalElement=Component.UNDEFINED, optionalMessage=Component.UNDEFINED, optionalEnum=Component.UNDEFINED, optionalUnion=Component.UNDEFINED, optionalArrayOf=Component.UNDEFINED, optionalObjectOf=Component.UNDEFINED, optionalObjectWithShapeAndNestedDescription=Component.UNDEFINED, optionalAny=Component.UNDEFINED, customProp=Component.UNDEFINED, customArrayProp=Component.UNDEFINED, id=Component.UNDEFINED, **kwargs): + self._prop_names = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'in', 'id'] + self._type = 'Table' + self._namespace = 'TableComponents' + self._valid_wildcard_attributes = ['data-', 'aria-'] + self.available_events = ['restyle', 'relayout', 'click'] + self.available_properties = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'in', 'id'] + self.available_wildcard_properties = ['data-', 'aria-'] + + _explicit_args = kwargs.pop('_explicit_args') + _locals = locals() + _locals.update(kwargs) # For wildcard attrs + args = {k: _locals[k] for k in _explicit_args if k != 'children'} + + for k in []: + if k not in args: + raise TypeError( + 'Required argument `' + k + '` was not specified.') + super(Table, self).__init__(children=children, **args) + + def __repr__(self): + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('Table(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') + else: + return ( + 'Table(' + + repr(getattr(self, self._prop_names[0], None)) + ')') diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py new file mode 100644 index 0000000..bb32cc7 --- /dev/null +++ b/tests/development/test_base_component.py @@ -0,0 +1,1042 @@ +from collections import OrderedDict +import collections +import inspect +import json +import os +import shutil +import unittest +import plotly + +from dash.development.base_component import ( + generate_class, + generate_class_string, + generate_class_file, + Component, + _explicitize_args, + js_to_py_type, + create_docstring, + parse_events +) + +Component._prop_names = ('id', 'a', 'children', 'style', ) +Component._type = 'TestComponent' +Component._namespace = 'test_namespace' +Component._valid_wildcard_attributes = ['data-', 'aria-'] + + +def nested_tree(): + """This tree has a few unique properties: + - children is mixed strings and components (as in c2) + - children is just components (as in c) + - children is just strings (as in c1) + - children is just a single component (as in c3, c4) + - children contains numbers (as in c2) + - children contains "None" items (as in c2) + """ + c1 = Component( + id='0.1.x.x.0', + children='string' + ) + c2 = Component( + id='0.1.x.x', + children=[10, None, 'wrap string', c1, 'another string', 4.51] + ) + c3 = Component( + id='0.1.x', + # children is just a component + children=c2 + ) + c4 = Component( + id='0.1', + children=c3 + ) + c5 = Component(id='0.0') + c = Component(id='0', children=[c5, c4]) + return c, c1, c2, c3, c4, c5 + + +class TestComponent(unittest.TestCase): + def test_init(self): + Component(a=3) + + def test_get_item_with_children(self): + c1 = Component(id='1') + c2 = Component(children=[c1]) + self.assertEqual(c2['1'], c1) + + def test_get_item_with_children_as_component_instead_of_list(self): + c1 = Component(id='1') + c2 = Component(id='2', children=c1) + self.assertEqual(c2['1'], c1) + + def test_get_item_with_nested_children_one_branch(self): + c1 = Component(id='1') + c2 = Component(id='2', children=[c1]) + c3 = Component(children=[c2]) + self.assertEqual(c2['1'], c1) + self.assertEqual(c3['2'], c2) + self.assertEqual(c3['1'], c1) + + def test_get_item_with_nested_children_two_branches(self): + c1 = Component(id='1') + c2 = Component(id='2', children=[c1]) + c3 = Component(id='3') + c4 = Component(id='4', children=[c3]) + c5 = Component(children=[c2, c4]) + self.assertEqual(c2['1'], c1) + self.assertEqual(c4['3'], c3) + self.assertEqual(c5['2'], c2) + self.assertEqual(c5['4'], c4) + self.assertEqual(c5['1'], c1) + self.assertEqual(c5['3'], c3) + + def test_get_item_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c, c1, c2, c3, c4, c5 = nested_tree() + self.assertEqual( + list(c.keys()), + [ + '0.0', + '0.1', + '0.1.x', + '0.1.x.x', + '0.1.x.x.0' + ] + ) + + # Try to get each item + for comp in [c1, c2, c3, c4, c5]: + self.assertEqual(c[comp.id], comp) + + # Get an item that doesn't exist + with self.assertRaises(KeyError): + c['x'] + + def test_len_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c = nested_tree()[0] + self.assertEqual( + len(c), + 5 + # 5 components + 5 + # c2 has 2 strings, 2 numbers, and a None + 1 # c1 has 1 string + ) + + def test_set_item_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + keys = [ + '0.0', + '0.1', + '0.1.x', + '0.1.x.x', + '0.1.x.x.0' + ] + c = nested_tree()[0] + + # Test setting items starting from the innermost item + for key in reversed(keys): + new_id = 'new {}'.format(key) + new_component = Component( + id=new_id, + children='new string' + ) + c[key] = new_component + self.assertEqual(c[new_id], new_component) + + def test_del_item_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c = nested_tree()[0] + for key in reversed(list(c.keys())): + c[key] + del c[key] + with self.assertRaises(KeyError): + c[key] + + def test_traverse_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c, c1, c2, c3, c4, c5 = nested_tree() + elements = [i for i in c.traverse()] + self.assertEqual( + elements, + c.children + [c3] + [c2] + c2.children + ) + + def test_iter_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c = nested_tree()[0] + keys = list(c.keys()) + # get a list of ids that __iter__ provides + iter_keys = [i for i in c] + self.assertEqual(keys, iter_keys) + + def test_to_plotly_json_with_nested_children_with_mixed_strings_and_without_lists(self): # noqa: E501 + c = nested_tree()[0] + Component._namespace + Component._type + + self.assertEqual(json.loads(json.dumps( + c.to_plotly_json(), + cls=plotly.utils.PlotlyJSONEncoder + )), { + 'type': 'TestComponent', + 'namespace': 'test_namespace', + 'props': { + 'children': [ + { + 'type': 'TestComponent', + 'namespace': 'test_namespace', + 'props': { + 'id': '0.0' + } + }, + { + 'type': 'TestComponent', + 'namespace': 'test_namespace', + 'props': { + 'children': { + 'type': 'TestComponent', + 'namespace': 'test_namespace', + 'props': { + 'children': { + 'type': 'TestComponent', + 'namespace': 'test_namespace', + 'props': { + 'children': [ + 10, + None, + 'wrap string', + { + 'type': 'TestComponent', + 'namespace': 'test_namespace', # noqa: E501 + 'props': { + 'children': 'string', + 'id': '0.1.x.x.0' + } + }, + 'another string', + 4.51 + ], + 'id': '0.1.x.x' + } + }, + 'id': '0.1.x' + } + }, + 'id': '0.1' + } + } + ], + 'id': '0' + } + }) + + def test_get_item_raises_key_if_id_doesnt_exist(self): + c = Component() + with self.assertRaises(KeyError): + c['1'] + + c1 = Component(id='1') + with self.assertRaises(KeyError): + c1['1'] + + c2 = Component(id='2', children=[c1]) + with self.assertRaises(KeyError): + c2['0'] + + c3 = Component(children='string with no id') + with self.assertRaises(KeyError): + c3['0'] + + def test_equality(self): + # TODO - Why is this the case? How is == being performed? + # __eq__ only needs __getitem__, __iter__, and __len__ + self.assertTrue(Component() == Component()) + self.assertTrue(Component() is not Component()) + + c1 = Component(id='1') + c2 = Component(id='2', children=[Component()]) + self.assertTrue(c1 == c2) + self.assertTrue(c1 is not c2) + + def test_set_item(self): + c1a = Component(id='1', children='Hello world') + c2 = Component(id='2', children=c1a) + self.assertEqual(c2['1'], c1a) + c1b = Component(id='1', children='Brave new world') + c2['1'] = c1b + self.assertEqual(c2['1'], c1b) + + def test_set_item_with_children_as_list(self): + c1 = Component(id='1') + c2 = Component(id='2', children=[c1]) + self.assertEqual(c2['1'], c1) + c3 = Component(id='3') + c2['1'] = c3 + self.assertEqual(c2['3'], c3) + + def test_set_item_with_nested_children(self): + c1 = Component(id='1') + c2 = Component(id='2', children=[c1]) + c3 = Component(id='3') + c4 = Component(id='4', children=[c3]) + c5 = Component(id='5', children=[c2, c4]) + + c3b = Component(id='3') + self.assertEqual(c5['3'], c3) + self.assertTrue(c5['3'] is not '3') + self.assertTrue(c5['3'] is not c3b) + + c5['3'] = c3b + self.assertTrue(c5['3'] is c3b) + self.assertTrue(c5['3'] is not c3) + + c2b = Component(id='2') + c5['2'] = c2b + self.assertTrue(c5['4'] is c4) + self.assertTrue(c5['2'] is not c2) + self.assertTrue(c5['2'] is c2b) + with self.assertRaises(KeyError): + c5['1'] + + def test_set_item_raises_key_error(self): + c1 = Component(id='1') + c2 = Component(id='2', children=[c1]) + with self.assertRaises(KeyError): + c2['3'] = Component(id='3') + + def test_del_item_from_list(self): + c1 = Component(id='1') + c2 = Component(id='2') + c3 = Component(id='3', children=[c1, c2]) + self.assertEqual(c3['1'], c1) + self.assertEqual(c3['2'], c2) + del c3['2'] + with self.assertRaises(KeyError): + c3['2'] + self.assertEqual(c3.children, [c1]) + + del c3['1'] + with self.assertRaises(KeyError): + c3['1'] + self.assertEqual(c3.children, []) + + def test_del_item_from_class(self): + c1 = Component(id='1') + c2 = Component(id='2', children=c1) + self.assertEqual(c2['1'], c1) + del c2['1'] + with self.assertRaises(KeyError): + c2['1'] + + self.assertEqual(c2.children, None) + + def test_to_plotly_json_without_children(self): + c = Component(id='a') + c._prop_names = ('id',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + {'namespace': 'basic', 'props': {'id': 'a'}, 'type': 'MyComponent'} + ) + + def test_to_plotly_json_with_null_arguments(self): + c = Component(id='a') + c._prop_names = ('id', 'style',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + {'namespace': 'basic', 'props': {'id': 'a'}, 'type': 'MyComponent'} + ) + + c = Component(id='a', style=None) + c._prop_names = ('id', 'style',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + { + 'namespace': 'basic', 'props': {'id': 'a', 'style': None}, + 'type': 'MyComponent' + } + ) + + def test_to_plotly_json_with_children(self): + c = Component(id='a', children='Hello World') + c._prop_names = ('id', 'children',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + { + 'namespace': 'basic', + 'props': { + 'id': 'a', + # TODO - Rename 'children' to 'children' + 'children': 'Hello World' + }, + 'type': 'MyComponent' + } + ) + + def test_to_plotly_json_with_nested_children(self): + c1 = Component(id='1', children='Hello World') + c1._prop_names = ('id', 'children',) + c1._type = 'MyComponent' + c1._namespace = 'basic' + + c2 = Component(id='2', children=c1) + c2._prop_names = ('id', 'children',) + c2._type = 'MyComponent' + c2._namespace = 'basic' + + c3 = Component(id='3', children='Hello World') + c3._prop_names = ('id', 'children',) + c3._type = 'MyComponent' + c3._namespace = 'basic' + + c4 = Component(id='4', children=[c2, c3]) + c4._prop_names = ('id', 'children',) + c4._type = 'MyComponent' + c4._namespace = 'basic' + + def to_dict(id, children): + return { + 'namespace': 'basic', + 'props': { + 'id': id, + 'children': children + }, + 'type': 'MyComponent' + } + + """ + self.assertEqual( + json.dumps(c4.to_plotly_json(), + cls=plotly.utils.PlotlyJSONEncoder), + json.dumps(to_dict('4', [ + to_dict('2', to_dict('1', 'Hello World')), + to_dict('3', 'Hello World') + ])) + ) + """ + + def test_to_plotly_json_with_wildcards(self): + c = Component(id='a', **{'aria-expanded': 'true', + 'data-toggle': 'toggled', + 'data-none': None}) + c._prop_names = ('id',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + {'namespace': 'basic', + 'props': { + 'aria-expanded': 'true', + 'data-toggle': 'toggled', + 'data-none': None, + 'id': 'a', + }, + 'type': 'MyComponent'} + ) + + def test_len(self): + self.assertEqual(len(Component()), 0) + self.assertEqual(len(Component(children='Hello World')), 1) + self.assertEqual(len(Component(children=Component())), 1) + self.assertEqual(len(Component(children=[Component(), Component()])), + 2) + self.assertEqual(len(Component(children=[ + Component(children=Component()), + Component() + ])), 3) + + def test_iter(self): + # keys, __contains__, items, values, and more are all mixin methods + # that we get for free by inheriting from the MutableMapping + # and behave as according to our implementation of __iter__ + + c = Component( + id='1', + children=[ + Component(id='2', children=[ + Component(id='3', children=Component(id='4')) + ]), + Component(id='5', children=[ + Component(id='6', children='Hello World') + ]), + Component(), + Component(children='Hello World'), + Component(children=Component(id='7')), + Component(children=[Component(id='8')]), + ] + ) + # test keys() + keys = [k for k in list(c.keys())] + self.assertEqual(keys, ['2', '3', '4', '5', '6', '7', '8']) + self.assertEqual([i for i in c], keys) + + # test values() + components = [i for i in list(c.values())] + self.assertEqual(components, [c[k] for k in keys]) + + # test __iter__() + for k in keys: + # test __contains__() + self.assertTrue(k in c) + + # test __items__ + items = [i for i in list(c.items())] + self.assertEqual(list(zip(keys, components)), items) + + def test_pop(self): + c2 = Component(id='2') + c = Component(id='1', children=c2) + c2_popped = c.pop('2') + self.assertTrue('2' not in c) + self.assertTrue(c2_popped is c2) + + +class TestGenerateClassFile(unittest.TestCase): + def setUp(self): + json_path = os.path.join('tests', 'development', 'metadata_test.json') + with open(json_path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.data = data + + # Create a folder for the new component file + os.makedirs('TableComponents') + + # Import string not included in generated class string + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import" + \ + " Component, _explicitize_args\n\n\n" + + # Class string generated from generate_class_string + self.component_class_string = import_string + generate_class_string( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + + # Class string written to file + generate_class_file( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + written_file_path = os.path.join( + 'TableComponents', "Table.py" + ) + with open(written_file_path, 'r') as f: + self.written_class_string = f.read() + + # The expected result for both class string and class file generation + expected_string_path = os.path.join( + 'tests', 'development', 'metadata_test.py' + ) + with open(expected_string_path, 'r') as f: + self.expected_class_string = f.read() + + def tearDown(self): + shutil.rmtree('TableComponents') + + def test_class_string(self): + self.assertEqual( + self.expected_class_string, + self.component_class_string + ) + + def test_class_file(self): + self.assertEqual( + self.expected_class_string, + self.written_class_string + ) + + +class TestGenerateClass(unittest.TestCase): + def setUp(self): + path = os.path.join('tests', 'development', 'metadata_test.json') + with open(path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.data = data + + self.ComponentClass = generate_class( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + + path = os.path.join( + 'tests', 'development', 'metadata_required_test.json' + ) + with open(path) as data_file: + json_string = data_file.read() + required_data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.required_data = required_data + + self.ComponentClassRequired = generate_class( + typename='TableRequired', + props=required_data['props'], + description=required_data['description'], + namespace='TableComponents' + ) + + def test_to_plotly_json(self): + c = self.ComponentClass() + self.assertEqual(c.to_plotly_json(), { + 'namespace': 'TableComponents', + 'type': 'Table', + 'props': { + 'children': None + } + }) + + c = self.ComponentClass(id='my-id') + self.assertEqual(c.to_plotly_json(), { + 'namespace': 'TableComponents', + 'type': 'Table', + 'props': { + 'children': None, + 'id': 'my-id' + } + }) + + c = self.ComponentClass(id='my-id', optionalArray=None) + self.assertEqual(c.to_plotly_json(), { + 'namespace': 'TableComponents', + 'type': 'Table', + 'props': { + 'children': None, + 'id': 'my-id', + 'optionalArray': None + } + }) + + def test_arguments_become_attributes(self): + kwargs = { + 'id': 'my-id', + 'children': 'text children', + 'optionalArray': [[1, 2, 3]] + } + component_instance = self.ComponentClass(**kwargs) + for k, v in list(kwargs.items()): + self.assertEqual(getattr(component_instance, k), v) + + def test_repr_single_default_argument(self): + c1 = self.ComponentClass('text children') + c2 = self.ComponentClass(children='text children') + self.assertEqual( + repr(c1), + "Table('text children')" + ) + self.assertEqual( + repr(c2), + "Table('text children')" + ) + + def test_repr_single_non_default_argument(self): + c = self.ComponentClass(id='my-id') + self.assertEqual( + repr(c), + "Table(id='my-id')" + ) + + def test_repr_multiple_arguments(self): + # Note how the order in which keyword arguments are supplied is + # not always equal to the order in the repr of the component + c = self.ComponentClass(id='my id', optionalArray=[1, 2, 3]) + self.assertEqual( + repr(c), + "Table(optionalArray=[1, 2, 3], id='my id')" + ) + + def test_repr_nested_arguments(self): + c1 = self.ComponentClass(id='1') + c2 = self.ComponentClass(id='2', children=c1) + c3 = self.ComponentClass(children=c2) + self.assertEqual( + repr(c3), + "Table(Table(children=Table(id='1'), id='2'))" + ) + + def test_repr_with_wildcards(self): + c = self.ComponentClass(id='1', **{"data-one": "one", + "aria-two": "two"}) + data_first = "Table(id='1', data-one='one', aria-two='two')" + aria_first = "Table(id='1', aria-two='two', data-one='one')" + repr_string = repr(c) + if not (repr_string == data_first or repr_string == aria_first): + raise Exception("%s\nDoes not equal\n%s\nor\n%s" % + (repr_string, data_first, aria_first)) + + def test_docstring(self): + assert_docstring(self.assertEqual, self.ComponentClass.__doc__) + + def test_events(self): + self.assertEqual( + self.ComponentClass().available_events, + ['restyle', 'relayout', 'click'] + ) + + # This one is kind of pointless now + def test_call_signature(self): + __init__func = self.ComponentClass.__init__ + # TODO: Will break in Python 3 + # http://stackoverflow.com/questions/2677185/ + self.assertEqual( + inspect.getargspec(__init__func).args, + ['self', + 'children', + 'optionalArray', + 'optionalBool', + 'optionalFunc', + 'optionalNumber', + 'optionalObject', + 'optionalString', + 'optionalSymbol', + 'optionalNode', + 'optionalElement', + 'optionalMessage', + 'optionalEnum', + 'optionalUnion', + 'optionalArrayOf', + 'optionalObjectOf', + 'optionalObjectWithShapeAndNestedDescription', + 'optionalAny', + 'customProp', + 'customArrayProp', + 'id'] if hasattr(inspect, 'signature') else [] + + + ) + self.assertEqual( + inspect.getargspec(__init__func).varargs, + None if hasattr(inspect, 'signature') else 'args' + ) + self.assertEqual( + inspect.getargspec(__init__func).keywords, + 'kwargs' + ) + if hasattr(inspect, 'signature'): + self.assertEqual( + [str(x) for x in inspect.getargspec(__init__func).defaults], + ['None'] + ['undefined'] * 19 + ) + + def test_required_props(self): + with self.assertRaises(Exception): + self.ComponentClassRequired() + self.ComponentClassRequired(id='test') + with self.assertRaises(Exception): + self.ComponentClassRequired(id='test', lahlah='test') + with self.assertRaises(Exception): + self.ComponentClassRequired(children='test') + + +class TestMetaDataConversions(unittest.TestCase): + def setUp(self): + path = os.path.join('tests', 'development', 'metadata_test.json') + with open(path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.data = data + + self.expected_arg_strings = OrderedDict([ + ['children', + 'a list of or a singular dash component, string or number'], + + ['optionalArray', 'list'], + + ['optionalBool', 'boolean'], + + ['optionalFunc', ''], + + ['optionalNumber', 'number'], + + ['optionalObject', 'dict'], + + ['optionalString', 'string'], + + ['optionalSymbol', ''], + + ['optionalElement', 'dash component'], + + ['optionalNode', + 'a list of or a singular dash component, string or number'], + + ['optionalMessage', ''], + + ['optionalEnum', 'a value equal to: \'News\', \'Photos\''], + + ['optionalUnion', 'string | number'], + + ['optionalArrayOf', 'list'], + + ['optionalObjectOf', + 'dict with strings as keys and values of type number'], + + ['optionalObjectWithShapeAndNestedDescription', '\n'.join([ + + "dict containing keys 'color', 'fontSize', 'figure'.", + "Those keys have the following types: ", + " - color (string; optional)", + " - fontSize (number; optional)", + " - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'.", # noqa: E501 + "Those keys have the following types: ", + " - data (list; optional): data is a collection of traces", + " - layout (dict; optional): layout describes the rest of the figure" # noqa: E501 + + ])], + + ['optionalAny', 'boolean | number | string | dict | list'], + + ['customProp', ''], + + ['customArrayProp', 'list'], + + ['data-*', 'string'], + + ['aria-*', 'string'], + + ['in', 'string'], + + ['id', 'string'], + + ['dashEvents', "a value equal to: 'restyle', 'relayout', 'click'"] + ]) + + def test_docstring(self): + docstring = create_docstring( + 'Table', + self.data['props'], + parse_events(self.data['props']), + self.data['description'], + ) + assert_docstring(self.assertEqual, docstring) + + def test_docgen_to_python_args(self): + + props = self.data['props'] + + for prop_name, prop in list(props.items()): + self.assertEqual( + js_to_py_type(prop['type']), + self.expected_arg_strings[prop_name] + ) + + +def assert_docstring(assertEqual, docstring): + for i, line in enumerate(docstring.split('\n')): + assertEqual(line, ([ + "A Table component.", + "This is a description of the component.", + "It's multiple lines long.", + '', + "Keyword arguments:", + "- children (a list of or a singular dash component, string or number; optional)", # noqa: E501 + "- optionalArray (list; optional): Description of optionalArray", + "- optionalBool (boolean; optional)", + "- optionalNumber (number; optional)", + "- optionalObject (dict; optional)", + "- optionalString (string; optional)", + + "- optionalNode (a list of or a singular dash component, " + "string or number; optional)", + + "- optionalElement (dash component; optional)", + "- optionalEnum (a value equal to: 'News', 'Photos'; optional)", + "- optionalUnion (string | number; optional)", + "- optionalArrayOf (list; optional)", + + "- optionalObjectOf (dict with strings as keys and values " + "of type number; optional)", + + "- optionalObjectWithShapeAndNestedDescription (optional): . " + "optionalObjectWithShapeAndNestedDescription has the " + "following type: dict containing keys " + "'color', 'fontSize', 'figure'.", + + "Those keys have the following types: ", + " - color (string; optional)", + " - fontSize (number; optional)", + + " - figure (optional): Figure is a plotly graph object. " + "figure has the following type: dict containing " + "keys 'data', 'layout'.", + + "Those keys have the following types: ", + " - data (list; optional): data is a collection of traces", + + " - layout (dict; optional): layout describes " + "the rest of the figure", + + "- optionalAny (boolean | number | string | dict | " + "list; optional)", + + "- customProp (optional)", + "- customArrayProp (list; optional)", + '- data-* (string; optional)', + '- aria-* (string; optional)', + '- in (string; optional)', + '- id (string; optional)', + '', + "Available events: 'restyle', 'relayout', 'click'", + ' ' + ])[i] + ) + + +class TestFlowMetaDataConversions(unittest.TestCase): + def setUp(self): + path = os.path.join('tests', 'development', 'flow_metadata_test.json') + with open(path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.data = data + + self.expected_arg_strings = OrderedDict([ + ['children', 'a list of or a singular dash component, string or number'], + + ['requiredString', 'string'], + + ['optionalString', 'string'], + + ['optionalBoolean', 'boolean'], + + ['optionalFunc', ''], + + ['optionalNode', 'a list of or a singular dash component, string or number'], + + ['optionalArray', 'list'], + + ['requiredUnion', 'string | number'], + + ['optionalSignature(shape)', '\n'.join([ + + "dict containing keys 'checked', 'children', 'customData', 'disabled', 'label', 'primaryText', 'secondaryText', 'style', 'value'.", + "Those keys have the following types: ", + "- checked (boolean; optional)", + "- children (a list of or a singular dash component, string or number; optional)", + "- customData (bool | number | str | dict | list; required): A test description", + "- disabled (boolean; optional)", + "- label (string; optional)", + "- primaryText (string; required): Another test description", + "- secondaryText (string; optional)", + "- style (dict; optional)", + "- value (bool | number | str | dict | list; required)" + + ])], + + ['requiredNested', '\n'.join([ + + "dict containing keys 'customData', 'value'.", + "Those keys have the following types: ", + "- customData (required): . customData has the following type: dict containing keys 'checked', 'children', 'customData', 'disabled', 'label', 'primaryText', 'secondaryText', 'style', 'value'.", + " Those keys have the following types: ", + " - checked (boolean; optional)", + " - children (a list of or a singular dash component, string or number; optional)", + " - customData (bool | number | str | dict | list; required)", + " - disabled (boolean; optional)", + " - label (string; optional)", + " - primaryText (string; required)", + " - secondaryText (string; optional)", + " - style (dict; optional)", + " - value (bool | number | str | dict | list; required)", + "- value (bool | number | str | dict | list; required)", + + ])], + ]) + + def test_docstring(self): + docstring = create_docstring( + 'Flow_component', + self.data['props'], + parse_events(self.data['props']), + self.data['description'], + ) + assert_flow_docstring(self.assertEqual, docstring) + + def test_docgen_to_python_args(self): + + props = self.data['props'] + + for prop_name, prop in list(props.items()): + self.assertEqual( + js_to_py_type(prop['flowType'], is_flow_type=True), + self.expected_arg_strings[prop_name] + ) + + +def assert_flow_docstring(assertEqual, docstring): + for i, line in enumerate(docstring.split('\n')): + assertEqual(line, ([ + "A Flow_component component.", + "This is a test description of the component.", + "It's multiple lines long.", + "", + "Keyword arguments:", + "- requiredString (string; required): A required string", + "- optionalString (string; optional): A string that isn't required.", + "- optionalBoolean (boolean; optional): A boolean test", + + "- optionalNode (a list of or a singular dash component, string or number; optional): " + "A node test", + + "- optionalArray (list; optional): An array test with a particularly ", + "long description that covers several lines. It includes the newline character ", + "and should span 3 lines in total.", + + "- requiredUnion (string | number; required)", + + "- optionalSignature(shape) (optional): This is a test of an object's shape. " + "optionalSignature(shape) has the following type: dict containing keys 'checked', " + "'children', 'customData', 'disabled', 'label', 'primaryText', 'secondaryText', " + "'style', 'value'.", + + " Those keys have the following types: ", + " - checked (boolean; optional)", + " - children (a list of or a singular dash component, string or number; optional)", + " - customData (bool | number | str | dict | list; required): A test description", + " - disabled (boolean; optional)", + " - label (string; optional)", + " - primaryText (string; required): Another test description", + " - secondaryText (string; optional)", + " - style (dict; optional)", + " - value (bool | number | str | dict | list; required)", + + "- requiredNested (required): . requiredNested has the following type: dict containing " + "keys 'customData', 'value'.", + + " Those keys have the following types: ", + + " - customData (required): . customData has the following type: dict containing " + "keys 'checked', 'children', 'customData', 'disabled', 'label', 'primaryText', " + "'secondaryText', 'style', 'value'.", + + " Those keys have the following types: ", + " - checked (boolean; optional)", + " - children (a list of or a singular dash component, string or number; optional)", + " - customData (bool | number | str | dict | list; required)", + " - disabled (boolean; optional)", + " - label (string; optional)", + " - primaryText (string; required)", + " - secondaryText (string; optional)", + " - style (dict; optional)", + " - value (bool | number | str | dict | list; required)", + " - value (bool | number | str | dict | list; required)", + "", + "Available events: " + ])[i] + ) diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py new file mode 100644 index 0000000..b0f8266 --- /dev/null +++ b/tests/development/test_component_loader.py @@ -0,0 +1,221 @@ +import collections +import json +import os +import shutil +import unittest +from dash.development.component_loader import load_components, generate_classes +from dash.development.base_component import ( + generate_class, + Component +) + +METADATA_PATH = 'metadata.json' + +METADATA_STRING = '''{ + "MyComponent.react.js": { + "props": { + "foo": { + "type": { + "name": "number" + }, + "required": false, + "description": "Description of prop foo.", + "defaultValue": { + "value": "42", + "computed": false + } + }, + "children": { + "type": { + "name": "object" + }, + "description": "Children", + "required": false + }, + "data-*": { + "type": { + "name": "string" + }, + "description": "Wildcard data", + "required": false + }, + "aria-*": { + "type": { + "name": "string" + }, + "description": "Wildcard aria", + "required": false + }, + "bar": { + "type": { + "name": "custom" + }, + "required": false, + "description": "Description of prop bar.", + "defaultValue": { + "value": "21", + "computed": false + } + }, + "baz": { + "type": { + "name": "union", + "value": [ + { + "name": "number" + }, + { + "name": "string" + } + ] + }, + "required": false, + "description": "" + } + }, + "description": "General component description.", + "methods": [] + }, + "A.react.js": { + "description": "", + "methods": [], + "props": { + "href": { + "type": { + "name": "string" + }, + "required": false, + "description": "The URL of a linked resource." + }, + "children": { + "type": { + "name": "object" + }, + "description": "Children", + "required": false + } + } + } +}''' +METADATA = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(METADATA_STRING) + + +class TestLoadComponents(unittest.TestCase): + def setUp(self): + with open(METADATA_PATH, 'w') as f: + f.write(METADATA_STRING) + + def tearDown(self): + os.remove(METADATA_PATH) + + def test_loadcomponents(self): + MyComponent = generate_class( + 'MyComponent', + METADATA['MyComponent.react.js']['props'], + METADATA['MyComponent.react.js']['description'], + 'default_namespace' + ) + + A = generate_class( + 'A', + METADATA['A.react.js']['props'], + METADATA['A.react.js']['description'], + 'default_namespace' + ) + + c = load_components(METADATA_PATH) + + MyComponentKwargs = { + 'foo': 'Hello World', + 'bar': 'Lah Lah', + 'baz': 'Lemons', + 'data-foo': 'Blah', + 'aria-bar': 'Seven', + 'children': 'Child' + } + AKwargs = { + 'children': 'Child', + 'href': 'Hello World' + } + + self.assertTrue( + isinstance(MyComponent(**MyComponentKwargs), Component) + ) + + self.assertEqual( + repr(MyComponent(**MyComponentKwargs)), + repr(c[0](**MyComponentKwargs)) + ) + + self.assertEqual( + repr(A(**AKwargs)), + repr(c[1](**AKwargs)) + ) + + +class TestGenerateClasses(unittest.TestCase): + def setUp(self): + with open(METADATA_PATH, 'w') as f: + f.write(METADATA_STRING) + os.makedirs('default_namespace') + + init_file_path = 'default_namespace/__init__.py' + with open(init_file_path, 'a'): + os.utime(init_file_path, None) + + def tearDown(self): + os.remove(METADATA_PATH) + shutil.rmtree('default_namespace') + + def test_loadcomponents(self): + MyComponent_runtime = generate_class( + 'MyComponent', + METADATA['MyComponent.react.js']['props'], + METADATA['MyComponent.react.js']['description'], + 'default_namespace' + ) + + A_runtime = generate_class( + 'A', + METADATA['A.react.js']['props'], + METADATA['A.react.js']['description'], + 'default_namespace' + ) + + generate_classes('default_namespace', METADATA_PATH) + from default_namespace.MyComponent import MyComponent \ + as MyComponent_buildtime + from default_namespace.A import A as A_buildtime + + MyComponentKwargs = { + 'foo': 'Hello World', + 'bar': 'Lah Lah', + 'baz': 'Lemons', + 'data-foo': 'Blah', + 'aria-bar': 'Seven', + 'baz': 'Lemons', + 'children': 'Child' + } + AKwargs = { + 'children': 'Child', + 'href': 'Hello World' + } + + self.assertTrue( + isinstance( + MyComponent_buildtime(**MyComponentKwargs), + Component + ) + ) + + self.assertEqual( + repr(MyComponent_buildtime(**MyComponentKwargs)), + repr(MyComponent_runtime(**MyComponentKwargs)), + ) + + self.assertEqual( + repr(A_runtime(**AKwargs)), + repr(A_buildtime(**AKwargs)) + ) diff --git a/tests/django_project/__init__.py b/tests/django_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/dynamic_dash/__init__.py b/tests/django_project/dynamic_dash/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/dynamic_dash/apps.py b/tests/django_project/dynamic_dash/apps.py new file mode 100644 index 0000000..5c71520 --- /dev/null +++ b/tests/django_project/dynamic_dash/apps.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + +# Initialise views for registering in dash.dash.BaseDashView._dashes +from .views import * + + +class App(AppConfig): + name = 'dynamic_dash' diff --git a/tests/django_project/dynamic_dash/migrations/__init__.py b/tests/django_project/dynamic_dash/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/dynamic_dash/models.py b/tests/django_project/dynamic_dash/models.py new file mode 100644 index 0000000..1dfab76 --- /dev/null +++ b/tests/django_project/dynamic_dash/models.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models + +# Create your models here. diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_first.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_first.js new file mode 100644 index 0000000..22c7799 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_first.js @@ -0,0 +1,7 @@ +window.tested = ['load_first']; +var ramdaTest = document.getElementById('ramda-test'); +if (ramdaTest) { + ramdaTest.innerHTML = R.join(' ', R.concat(['hello'], ['world']).map(function(x) { + return _.capitalize(x); + })); +} diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_ignored.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_ignored.js new file mode 100644 index 0000000..668e477 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/load_ignored.js @@ -0,0 +1 @@ +window.tested = 'IGNORED'; // Break the chain. \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_css/nested.css b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_css/nested.css new file mode 100644 index 0000000..24ba3d2 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_css/nested.css @@ -0,0 +1,3 @@ +#content { + padding: 8px; +} \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after.js new file mode 100644 index 0000000..6f520fd --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after.js @@ -0,0 +1 @@ +window.tested.push('load_after'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after1.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after1.js new file mode 100644 index 0000000..1629d39 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after1.js @@ -0,0 +1 @@ +window.tested.push('load_after1'); diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after10.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after10.js new file mode 100644 index 0000000..fcfdb59 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after10.js @@ -0,0 +1 @@ +window.tested.push('load_after10'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after11.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after11.js new file mode 100644 index 0000000..bd11cd2 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after11.js @@ -0,0 +1 @@ +window.tested.push('load_after11'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after2.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after2.js new file mode 100644 index 0000000..0b76a55 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after2.js @@ -0,0 +1 @@ +window.tested.push('load_after2'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after3.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after3.js new file mode 100644 index 0000000..d913af9 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after3.js @@ -0,0 +1 @@ +window.tested.push('load_after3'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after4.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after4.js new file mode 100644 index 0000000..2507e38 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_after4.js @@ -0,0 +1 @@ +window.tested.push('load_after4'); \ No newline at end of file diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_last.js b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_last.js new file mode 100644 index 0000000..285aa60 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/nested_js/load_last.js @@ -0,0 +1 @@ +document.getElementById('tested').innerHTML = JSON.stringify(window.tested); diff --git a/tests/django_project/dynamic_dash/static/dynamic_dash/assets/reset.css b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/reset.css new file mode 100644 index 0000000..c7c7b58 --- /dev/null +++ b/tests/django_project/dynamic_dash/static/dynamic_dash/assets/reset.css @@ -0,0 +1,2 @@ +body {margin: 0;} +button {height: 18px} diff --git a/tests/django_project/dynamic_dash/views.py b/tests/django_project/dynamic_dash/views.py new file mode 100644 index 0000000..ca10895 --- /dev/null +++ b/tests/django_project/dynamic_dash/views.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime +from multiprocessing import Value + +import dash_core_components as dcc +import dash_html_components as html +import dash_flow_example +import dash_dangerously_set_inner_html + +from dash import BaseDashView +from dash.dependencies import Input, Output +from dash.exceptions import PreventUpdate + + +class DashView(BaseDashView): + def __init__(self, **kwargs): + super(DashView, self).__init__(**kwargs) + + self.dash.config.suppress_callback_exceptions = True + self.dash.config.routes_pathname_prefix = '/dash/{}/'.format(self.dash_name) + self.dash.css.config.serve_locally = True + self.dash.scripts.config.serve_locally = True + + def get(self, request, *args, **kwargs): + return self.serve_dash_index(request, self.dash_name, *args, **kwargs) + + def _dash_component_suites(self, request, *args, **kwargs): + self.dash._generate_scripts_html() + self.dash._generate_css_dist_html() + + return super(DashView, self)._dash_component_suites(request, *args, **kwargs) + + +class DashSimpleCallback(DashView): + dash_name = 'dash01' + + call_count = Value('i', 0) + + def __init__(self, **kwargs): + super(DashSimpleCallback, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + dcc.Input( + id='input', + value='initial value' + ), + html.Div( + html.Div([ + 1.5, + None, + 'string', + html.Div(id='output-1') + ]) + ) + ]) + + self.dash.callback(Output('output-1', 'children'), + [Input('input', 'value')])(self.update_output) + + def update_output(self, value): + self.call_count.value = self.call_count.value + 1 + + return value + + +class DashWildcardCallback(DashView): + dash_name = 'dash02' + + call_count = Value('i', 0) + + def __init__(self, **kwargs): + super(DashWildcardCallback, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + dcc.Input( + id='input', + value='initial value' + ), + html.Div( + html.Div([ + 1.5, + None, + 'string', + html.Div(id='output-1', **{'data-cb': 'initial value', + 'aria-cb': 'initial value'}) + ]) + ) + ]) + + self.dash.callback(Output('output-1', 'data-cb'), + [Input('input', 'value')])(self.update_data) + self.dash.callback(Output('output-1', 'children'), + [Input('output-1', 'data-cb')])(self.update_text) + + def update_data(self, value): + self.call_count.value = self.call_count.value + 1 + + return value + + def update_text(self, data): + return data + + +class DashAbortedCallback(DashView): + dash_name = 'dash03' + + initial_input = 'initial input' + initial_output = 'initial output' + + callback1_count = Value('i', 0) + callback2_count = Value('i', 0) + + def __init__(self, **kwargs): + super(DashAbortedCallback, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + dcc.Input(id='input', value=self.initial_input), + html.Div(self.initial_output, id='output1'), + html.Div(self.initial_output, id='output2'), + ]) + + self.dash.callback(Output('output1', 'children'), + [Input('input', 'value')])(self.callback1) + self.dash.callback(Output('output2', 'children'), + [Input('output1', 'children')])(self.callback2) + + def callback1(self, value): + self.callback1_count.value = self.callback1_count.value + 1 + raise PreventUpdate("testing callback does not update") + return value + + def callback2(self, value): + self.callback2_count.value = self.callback2_count.value + 1 + return value + + +class DashWildcardDataAttributes(DashView): + dash_name = 'dash04' + + test_time = datetime.datetime(2012, 1, 10, 2, 3) + test_date = datetime.date(test_time.year, test_time.month, test_time.day) + + def __init__(self, **kwargs): + super(DashWildcardDataAttributes, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + html.Div( + id="inner-element", + **{ + 'data-string': 'multiple words', + 'data-number': 512, + 'data-none': None, + 'data-date': self.test_date, + 'aria-progress': 5 + } + ) + ], id='data-element') + + +class DashFlowComponent(DashView): + dash_name = 'dash05' + + def __init__(self, **kwargs): + super(DashFlowComponent, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + dash_flow_example.ExampleReactComponent( + id='react', + value='my-value', + label='react component' + ), + dash_flow_example.ExampleFlowComponent( + id='flow', + value='my-value', + label='flow component' + ), + html.Hr(), + html.Div(id='output') + ]) + + self.dash.callback(Output('output', 'children'), + [Input('react', 'value'), + Input('flow', 'value')])(self.display_output) + + def display_output(self, react_value, flow_value): + return html.Div([ + 'You have entered {} and {}'.format(react_value, flow_value), + html.Hr(), + html.Label('Flow Component Docstring'), + html.Pre(dash_flow_example.ExampleFlowComponent.__doc__), + html.Hr(), + html.Label('React PropTypes Component Docstring'), + html.Pre(dash_flow_example.ExampleReactComponent.__doc__), + html.Div(id='waitfor') + ]) + + +class DashNoPropsComponent(DashView): + dash_name = 'dash06' + + def __init__(self, **kwargs): + super(DashNoPropsComponent, self).__init__(**kwargs) + + self.dash.layout = html.Div([ + dash_dangerously_set_inner_html.DangerouslySetInnerHTML(''' +

No Props Component

+ ''') + ]) + + +class DashMetaTags(DashView): + dash_name = 'dash07' + + metas = [ + {'name': 'description', 'content': 'my dash app'}, + {'name': 'custom', 'content': 'customized'}, + ] + + def __init__(self, **kwargs): + super(DashMetaTags, self).__init__(dash_meta_tags=self.metas, **kwargs) + + self.dash.layout = html.Div(id='content') + + +class DashIndexCustomization(DashView): + dash_name = 'dash08' + + def __init__(self, **kwargs): + super(DashIndexCustomization, self).__init__(**kwargs) + + self.dash.index_string = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + +
My custom header
+
+ {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + + + ''' + + self.dash.layout = html.Div('Dash app', id='app') + + +class DashAssets(DashView): + dash_name = 'dash09' + dash_template = ''' + + + + {%metas%} + {%title%} + {%css%} + + +
+ {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + ''' + dash_assets_folder = 'dynamic_dash/assets' + dash_assets_ignore = '*ignored.*' + + def __init__(self, **kwargs): + super(DashAssets, self).__init__(**kwargs) + + self.dash.layout = html.Div([html.Div(id='content'), dcc.Input(id='test')], id='layout') + + +class DashInvalidIndexString(DashView): + dash_name = 'dash10' + + def __init__(self, **kwargs): + super(DashInvalidIndexString, self).__init__(**kwargs) + + self.dash.layout = html.Div() + + + +class DashExternalFilesInit(DashView): + dash_name = 'dash11' + + js_files = [ + 'https://www.google-analytics.com/analytics.js', + {'src': 'https://cdn.polyfill.io/v2/polyfill.min.js'}, + { + 'src': 'https://cdnjs.cloudflare.com/ajax/libs/ramda/0.25.0/ramda.min.js', + 'integrity': 'sha256-YN22NHB7zs5+LjcHWgk3zL0s+CRnzCQzDOFnndmUamY=', + 'crossorigin': 'anonymous' + }, + { + 'src': 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.min.js', + 'integrity': 'sha256-VKITM616rVzV+MI3kZMNUDoY5uTsuSl1ZvEeZhNoJVk=', + 'crossorigin': 'anonymous' + } + ] + + css_files = [ + 'https://codepen.io/chriddyp/pen/bWLwgP.css', + { + 'href': 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css', + 'rel': 'stylesheet', + 'integrity': 'sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO', + 'crossorigin': 'anonymous' + } + ] + + def __init__(self, **kwargs): + super(DashExternalFilesInit, self).__init__(dash_external_scripts=self.js_files, + dash_external_stylesheets=self.css_files, **kwargs) + + self.dash.index_string = ''' + + + + {%metas%} + {%title%} + {%css%} + + +
+
Hello World
+ + {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + ''' + + self.dash.layout = html.Div() + + +class DashFuncLayoutAccepted(DashView): + dash_name = 'dash12' + + def __init__(self, **kwargs): + super(DashFuncLayoutAccepted, self).__init__(**kwargs) + + def create_layout(): + return html.Div('Hello World') + + self.dash.layout = create_layout diff --git a/tests/django_project/manage.py b/tests/django_project/manage.py new file mode 100644 index 0000000..f522e00 --- /dev/null +++ b/tests/django_project/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/tests/django_project/project/__init__.py b/tests/django_project/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/project/settings.py b/tests/django_project/project/settings.py new file mode 100644 index 0000000..c020b4e --- /dev/null +++ b/tests/django_project/project/settings.py @@ -0,0 +1,87 @@ +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'a8nlqf7c!k_glgd2xsdz4=&^q53_g9#4zks=nf3&3u@4!^0^i-' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'dynamic_dash.apps.App', + 'static_dash.apps.App', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'project.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'project.wsgi.application' + + +# Database + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + # 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Internationalization + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = '/static/' + +STATIC_ROOT = os.path.join(BASE_DIR, 'static') diff --git a/tests/django_project/project/urls.py b/tests/django_project/project/urls.py new file mode 100644 index 0000000..f950721 --- /dev/null +++ b/tests/django_project/project/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import url, include +from django.conf import settings +from django.contrib.staticfiles.urls import staticfiles_urlpatterns + + +urlpatterns = [ + url(r'^dash/', include('dash.urls')), +] + + +if settings.DEBUG: + from django.conf.urls.static import static + + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + # urlpatterns += staticfiles_urlpatterns() diff --git a/tests/django_project/project/wsgi.py b/tests/django_project/project/wsgi.py new file mode 100644 index 0000000..2ef9a16 --- /dev/null +++ b/tests/django_project/project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") + +application = get_wsgi_application() diff --git a/tests/django_project/static/dynamic_dash/assets/load_first.js b/tests/django_project/static/dynamic_dash/assets/load_first.js new file mode 100644 index 0000000..22c7799 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/load_first.js @@ -0,0 +1,7 @@ +window.tested = ['load_first']; +var ramdaTest = document.getElementById('ramda-test'); +if (ramdaTest) { + ramdaTest.innerHTML = R.join(' ', R.concat(['hello'], ['world']).map(function(x) { + return _.capitalize(x); + })); +} diff --git a/tests/django_project/static/dynamic_dash/assets/load_ignored.js b/tests/django_project/static/dynamic_dash/assets/load_ignored.js new file mode 100644 index 0000000..668e477 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/load_ignored.js @@ -0,0 +1 @@ +window.tested = 'IGNORED'; // Break the chain. \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_css/nested.css b/tests/django_project/static/dynamic_dash/assets/nested_css/nested.css new file mode 100644 index 0000000..24ba3d2 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_css/nested.css @@ -0,0 +1,3 @@ +#content { + padding: 8px; +} \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after.js new file mode 100644 index 0000000..6f520fd --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after.js @@ -0,0 +1 @@ +window.tested.push('load_after'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after1.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after1.js new file mode 100644 index 0000000..1629d39 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after1.js @@ -0,0 +1 @@ +window.tested.push('load_after1'); diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after10.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after10.js new file mode 100644 index 0000000..fcfdb59 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after10.js @@ -0,0 +1 @@ +window.tested.push('load_after10'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after11.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after11.js new file mode 100644 index 0000000..bd11cd2 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after11.js @@ -0,0 +1 @@ +window.tested.push('load_after11'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after2.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after2.js new file mode 100644 index 0000000..0b76a55 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after2.js @@ -0,0 +1 @@ +window.tested.push('load_after2'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after3.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after3.js new file mode 100644 index 0000000..d913af9 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after3.js @@ -0,0 +1 @@ +window.tested.push('load_after3'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_after4.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after4.js new file mode 100644 index 0000000..2507e38 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_after4.js @@ -0,0 +1 @@ +window.tested.push('load_after4'); \ No newline at end of file diff --git a/tests/django_project/static/dynamic_dash/assets/nested_js/load_last.js b/tests/django_project/static/dynamic_dash/assets/nested_js/load_last.js new file mode 100644 index 0000000..285aa60 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/nested_js/load_last.js @@ -0,0 +1 @@ +document.getElementById('tested').innerHTML = JSON.stringify(window.tested); diff --git a/tests/django_project/static/dynamic_dash/assets/reset.css b/tests/django_project/static/dynamic_dash/assets/reset.css new file mode 100644 index 0000000..c7c7b58 --- /dev/null +++ b/tests/django_project/static/dynamic_dash/assets/reset.css @@ -0,0 +1,2 @@ +body {margin: 0;} +button {height: 18px} diff --git a/tests/django_project/static/static_dash/assets/load_first.js b/tests/django_project/static/static_dash/assets/load_first.js new file mode 100644 index 0000000..22c7799 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/load_first.js @@ -0,0 +1,7 @@ +window.tested = ['load_first']; +var ramdaTest = document.getElementById('ramda-test'); +if (ramdaTest) { + ramdaTest.innerHTML = R.join(' ', R.concat(['hello'], ['world']).map(function(x) { + return _.capitalize(x); + })); +} diff --git a/tests/django_project/static/static_dash/assets/load_ignored.js b/tests/django_project/static/static_dash/assets/load_ignored.js new file mode 100644 index 0000000..668e477 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/load_ignored.js @@ -0,0 +1 @@ +window.tested = 'IGNORED'; // Break the chain. \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_css/nested.css b/tests/django_project/static/static_dash/assets/nested_css/nested.css new file mode 100644 index 0000000..24ba3d2 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_css/nested.css @@ -0,0 +1,3 @@ +#content { + padding: 8px; +} \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after.js b/tests/django_project/static/static_dash/assets/nested_js/load_after.js new file mode 100644 index 0000000..6f520fd --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after.js @@ -0,0 +1 @@ +window.tested.push('load_after'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after1.js b/tests/django_project/static/static_dash/assets/nested_js/load_after1.js new file mode 100644 index 0000000..1629d39 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after1.js @@ -0,0 +1 @@ +window.tested.push('load_after1'); diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after10.js b/tests/django_project/static/static_dash/assets/nested_js/load_after10.js new file mode 100644 index 0000000..fcfdb59 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after10.js @@ -0,0 +1 @@ +window.tested.push('load_after10'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after11.js b/tests/django_project/static/static_dash/assets/nested_js/load_after11.js new file mode 100644 index 0000000..bd11cd2 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after11.js @@ -0,0 +1 @@ +window.tested.push('load_after11'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after2.js b/tests/django_project/static/static_dash/assets/nested_js/load_after2.js new file mode 100644 index 0000000..0b76a55 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after2.js @@ -0,0 +1 @@ +window.tested.push('load_after2'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after3.js b/tests/django_project/static/static_dash/assets/nested_js/load_after3.js new file mode 100644 index 0000000..d913af9 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after3.js @@ -0,0 +1 @@ +window.tested.push('load_after3'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_after4.js b/tests/django_project/static/static_dash/assets/nested_js/load_after4.js new file mode 100644 index 0000000..2507e38 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_after4.js @@ -0,0 +1 @@ +window.tested.push('load_after4'); \ No newline at end of file diff --git a/tests/django_project/static/static_dash/assets/nested_js/load_last.js b/tests/django_project/static/static_dash/assets/nested_js/load_last.js new file mode 100644 index 0000000..285aa60 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/nested_js/load_last.js @@ -0,0 +1 @@ +document.getElementById('tested').innerHTML = JSON.stringify(window.tested); diff --git a/tests/django_project/static/static_dash/assets/reset.css b/tests/django_project/static/static_dash/assets/reset.css new file mode 100644 index 0000000..c7c7b58 --- /dev/null +++ b/tests/django_project/static/static_dash/assets/reset.css @@ -0,0 +1,2 @@ +body {margin: 0;} +button {height: 18px} diff --git a/tests/django_project/static_dash/__init__.py b/tests/django_project/static_dash/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/static_dash/apps.py b/tests/django_project/static_dash/apps.py new file mode 100644 index 0000000..44d89da --- /dev/null +++ b/tests/django_project/static_dash/apps.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + +# Initialise views for registering in dash.dash.BaseDashView._dashes +from .views import * + + +class App(AppConfig): + name = 'static_dash' diff --git a/tests/django_project/static_dash/migrations/__init__.py b/tests/django_project/static_dash/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/static_dash/models.py b/tests/django_project/static_dash/models.py new file mode 100644 index 0000000..1dfab76 --- /dev/null +++ b/tests/django_project/static_dash/models.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models + +# Create your models here. diff --git a/tests/django_project/static_dash/static/static_dash/assets/load_first.js b/tests/django_project/static_dash/static/static_dash/assets/load_first.js new file mode 100644 index 0000000..22c7799 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/load_first.js @@ -0,0 +1,7 @@ +window.tested = ['load_first']; +var ramdaTest = document.getElementById('ramda-test'); +if (ramdaTest) { + ramdaTest.innerHTML = R.join(' ', R.concat(['hello'], ['world']).map(function(x) { + return _.capitalize(x); + })); +} diff --git a/tests/django_project/static_dash/static/static_dash/assets/load_ignored.js b/tests/django_project/static_dash/static/static_dash/assets/load_ignored.js new file mode 100644 index 0000000..668e477 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/load_ignored.js @@ -0,0 +1 @@ +window.tested = 'IGNORED'; // Break the chain. \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_css/nested.css b/tests/django_project/static_dash/static/static_dash/assets/nested_css/nested.css new file mode 100644 index 0000000..24ba3d2 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_css/nested.css @@ -0,0 +1,3 @@ +#content { + padding: 8px; +} \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after.js new file mode 100644 index 0000000..6f520fd --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after.js @@ -0,0 +1 @@ +window.tested.push('load_after'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after1.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after1.js new file mode 100644 index 0000000..1629d39 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after1.js @@ -0,0 +1 @@ +window.tested.push('load_after1'); diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after10.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after10.js new file mode 100644 index 0000000..fcfdb59 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after10.js @@ -0,0 +1 @@ +window.tested.push('load_after10'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after11.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after11.js new file mode 100644 index 0000000..bd11cd2 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after11.js @@ -0,0 +1 @@ +window.tested.push('load_after11'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after2.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after2.js new file mode 100644 index 0000000..0b76a55 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after2.js @@ -0,0 +1 @@ +window.tested.push('load_after2'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after3.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after3.js new file mode 100644 index 0000000..d913af9 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after3.js @@ -0,0 +1 @@ +window.tested.push('load_after3'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after4.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after4.js new file mode 100644 index 0000000..2507e38 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_after4.js @@ -0,0 +1 @@ +window.tested.push('load_after4'); \ No newline at end of file diff --git a/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_last.js b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_last.js new file mode 100644 index 0000000..285aa60 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/nested_js/load_last.js @@ -0,0 +1 @@ +document.getElementById('tested').innerHTML = JSON.stringify(window.tested); diff --git a/tests/django_project/static_dash/static/static_dash/assets/reset.css b/tests/django_project/static_dash/static/static_dash/assets/reset.css new file mode 100644 index 0000000..c7c7b58 --- /dev/null +++ b/tests/django_project/static_dash/static/static_dash/assets/reset.css @@ -0,0 +1,2 @@ +body {margin: 0;} +button {height: 18px} diff --git a/tests/django_project/static_dash/views.py b/tests/django_project/static_dash/views.py new file mode 100644 index 0000000..86a2d56 --- /dev/null +++ b/tests/django_project/static_dash/views.py @@ -0,0 +1,371 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime +from multiprocessing import Value + +import dash_core_components as dcc +import dash_html_components as html +import dash_flow_example +import dash_dangerously_set_inner_html + +from dash import BaseDashView, Dash +from dash.dependencies import Input, Output +from dash.exceptions import PreventUpdate + + +class DashView(BaseDashView): + @staticmethod + def set_config(dash, dash_name): + dash.config.suppress_callback_exceptions = True + dash.config.routes_pathname_prefix = '/dash/{}/'.format(dash_name) + dash.css.config.serve_locally = True + dash.scripts.config.serve_locally = True + + def get(self, request, *args, **kwargs): + return self.serve_dash_index(request, self.dash_name, *args, **kwargs) + + +class DashSimpleCallback(DashView): + dash_name = 'static_dash01' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + dcc.Input( + id='input', + value='initial value' + ), + html.Div( + html.Div([ + 1.5, + None, + 'string', + html.Div(id='output-1') + ]) + ) + ]) + + call_count = Value('i', 0) + + @staticmethod + @dash.callback(Output('output-1', 'children'), [Input('input', 'value')]) + def update_output(value): + DashSimpleCallback.call_count.value = DashSimpleCallback.call_count.value + 1 + + return value + + +class DashWildcardCallback(DashView): + dash_name = 'static_dash02' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + dcc.Input( + id='input', + value='initial value' + ), + html.Div( + html.Div([ + 1.5, + None, + 'string', + html.Div(id='output-1', **{'data-cb': 'initial value', + 'aria-cb': 'initial value'}) + ]) + ) + ]) + + call_count = Value('i', 0) + + @classmethod # As an opportunity + def update_data(cls, value): + cls.call_count.value = cls.call_count.value + 1 + + return value + + @staticmethod # As an opportunity + @dash.callback(Output('output-1', 'children'), [Input('output-1', 'data-cb')]) + def update_text(data): + return data + +DashWildcardCallback.dash.callback(Output('output-1', 'data-cb'), + [Input('input', 'value')])(DashWildcardCallback.update_data) + + +class DashAbortedCallback(DashView): + dash_name = 'static_dash03' + + initial_input = 'initial input' + initial_output = 'initial output' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + dcc.Input(id='input', value=initial_input), + html.Div(initial_output, id='output1'), + html.Div(initial_output, id='output2'), + ]) + + callback1_count = Value('i', 0) + callback2_count = Value('i', 0) + + @classmethod # As an opportunity + def callback1(cls, value): + cls.callback1_count.value = cls.callback1_count.value + 1 + raise PreventUpdate("testing callback does not update") + return value + + @staticmethod # As an opportunity + @dash.callback(Output('output2', 'children'), [Input('output1', 'children')]) + def callback2(value): + DashAbortedCallback.callback2_count.value = DashAbortedCallback.callback2_count.value + 1 + return value + +DashAbortedCallback.dash.callback(Output('output1', 'children'), + [Input('input', 'value')])(DashAbortedCallback.callback1) + + +class DashWildcardDataAttributes(DashView): + dash_name = 'static_dash04' + + test_time = datetime.datetime(2012, 1, 10, 2, 3) + test_date = datetime.date(test_time.year, test_time.month, test_time.day) + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + html.Div( + id="inner-element", + **{ + 'data-string': 'multiple words', + 'data-number': 512, + 'data-none': None, + 'data-date': test_date, + 'aria-progress': 5 + } + ) + ], id='data-element') + + +class DashFlowComponent(DashView): + dash_name = 'static_dash05' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + dash_flow_example.ExampleReactComponent( + id='react', + value='my-value', + label='react component' + ), + dash_flow_example.ExampleFlowComponent( + id='flow', + value='my-value', + label='flow component' + ), + html.Hr(), + html.Div(id='output') + ]) + + @staticmethod + @dash.callback(Output('output', 'children'), + [Input('react', 'value'), Input('flow', 'value')]) + def display_output(react_value, flow_value): + return html.Div([ + 'You have entered {} and {}'.format(react_value, flow_value), + html.Hr(), + html.Label('Flow Component Docstring'), + html.Pre(dash_flow_example.ExampleFlowComponent.__doc__), + html.Hr(), + html.Label('React PropTypes Component Docstring'), + html.Pre(dash_flow_example.ExampleReactComponent.__doc__), + html.Div(id='waitfor') + ]) + + +class DashNoPropsComponent(DashView): + dash_name = 'static_dash06' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([ + dash_dangerously_set_inner_html.DangerouslySetInnerHTML(''' +

No Props Component

+ ''') + ]) + + +class DashMetaTags(DashView): + dash_name = 'static_dash07' + + metas = [ + {'name': 'description', 'content': 'my dash app'}, + {'name': 'custom', 'content': 'customized'}, + ] + + dash = Dash(meta_tags=metas) + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div(id='content') + + +class DashIndexCustomization(DashView): + dash_name = 'static_dash08' + + dash = Dash() + dash.index_string = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + +
My custom header
+
+ {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + + + ''' + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div('Dash app', id='app') + + +class DashAssets(DashView): + dash_name = 'static_dash09' + dash_template = ''' + + + + {%metas%} + {%title%} + {%css%} + + +
+ {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + ''' + dash_assets_folder = 'static_dash/assets' + dash_assets_ignore = '*ignored.*' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div([html.Div(id='content'), dcc.Input(id='test')], id='layout') + + +class DashInvalidIndexString(DashView): + dash_name = 'static_dash10' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div() + + +class DashExternalFilesInit(DashView): + dash_name = 'static_dash11' + + js_files = [ + 'https://www.google-analytics.com/analytics.js', + {'src': 'https://cdn.polyfill.io/v2/polyfill.min.js'}, + { + 'src': 'https://cdnjs.cloudflare.com/ajax/libs/ramda/0.25.0/ramda.min.js', + 'integrity': 'sha256-YN22NHB7zs5+LjcHWgk3zL0s+CRnzCQzDOFnndmUamY=', + 'crossorigin': 'anonymous' + }, + { + 'src': 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.min.js', + 'integrity': 'sha256-VKITM616rVzV+MI3kZMNUDoY5uTsuSl1ZvEeZhNoJVk=', + 'crossorigin': 'anonymous' + } + ] + + css_files = [ + 'https://codepen.io/chriddyp/pen/bWLwgP.css', + { + 'href': 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css', + 'rel': 'stylesheet', + 'integrity': 'sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO', + 'crossorigin': 'anonymous' + } + ] + + dash = Dash(external_scripts=js_files, external_stylesheets=css_files) + dash.index_string = ''' + + + + {%metas%} + {%title%} + {%css%} + + +
+
Hello World
+ + {%app_entry%} +
+ {%config%} + {%scripts%} +
+ + + ''' + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = html.Div() + + +class DashFuncLayoutAccepted(DashView): + dash_name = 'static_dash12' + + dash = Dash() + + DashView.set_config(dash, dash_name) # As an opportunity + + dash.layout = lambda: html.Div('Hello World') diff --git a/tests/django_project/tests/IntegrationTests.py b/tests/django_project/tests/IntegrationTests.py new file mode 100644 index 0000000..f90f9a4 --- /dev/null +++ b/tests/django_project/tests/IntegrationTests.py @@ -0,0 +1,61 @@ +import sys +import time + +from selenium import webdriver +from django.test import LiveServerTestCase + +from .utils import invincible, wait_for + + +class IntegrationTests(LiveServerTestCase): + def wait_for_element_by_id(self, id): + wait_for(lambda: None is not invincible( + lambda: self.driver.find_element_by_id(id) + ), timeout=5) + return self.driver.find_element_by_id(id) + + @classmethod + def setUpClass(cls): + super(IntegrationTests, cls).setUpClass() + + cls.driver = webdriver.Chrome() + + @classmethod + def tearDownClass(cls): + super(IntegrationTests, cls).tearDownClass() + + cls.driver.quit() + + def tearDown(self): + time.sleep(2) + + def open(self, dash): + # Visit the dash page + self.driver.get('{}/{}'.format(self.live_server_url, dash)) + time.sleep(1) + + # Inject an error and warning logger + logger = ''' + window.tests = {}; + window.tests.console = {error: [], warn: [], log: []}; + + var _log = console.log; + var _warn = console.warn; + var _error = console.error; + + console.log = function() { + window.tests.console.log.push({method: 'log', arguments: arguments}); + return _log.apply(console, arguments); + }; + + console.warn = function() { + window.tests.console.warn.push({method: 'warn', arguments: arguments}); + return _warn.apply(console, arguments); + }; + + console.error = function() { + window.tests.console.error.push({method: 'error', arguments: arguments}); + return _error.apply(console, arguments); + }; + ''' + self.driver.execute_script(logger) diff --git a/tests/django_project/tests/__init__.py b/tests/django_project/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_project/tests/test_integration.py b/tests/django_project/tests/test_integration.py new file mode 100644 index 0000000..6ea8482 --- /dev/null +++ b/tests/django_project/tests/test_integration.py @@ -0,0 +1,294 @@ +import itertools +import re +import json + +import dash_html_components as html + +from .IntegrationTests import IntegrationTests +from .utils import assert_clean_console, wait_for + +from ..dynamic_dash import views as dynamic_views +from ..static_dash import views as static_views + + +class Tests(IntegrationTests): + def _simple_callback(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + output1 = self.wait_for_element_by_id('output-1') + wait_for(lambda: output1.text == 'initial value') + + input1 = self.wait_for_element_by_id('input') + input1.clear() + + input1.send_keys('hello world') + + output1 = lambda: self.wait_for_element_by_id('output-1') + wait_for(lambda: output1().text == 'hello world') + + self.assertEqual( + view_class.call_count.value, + # an initial call to retrieve the first value + 1 + + # one for each hello world character + len('hello world') + ) + + assert_clean_console(self) + + def _wildcard_callback(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + output1 = self.wait_for_element_by_id('output-1') + wait_for(lambda: output1.text == 'initial value') + + input1 = self.wait_for_element_by_id('input') + input1.clear() + + input1.send_keys('hello world') + + output1 = lambda: self.wait_for_element_by_id('output-1') + wait_for(lambda: output1().text == 'hello world') + + self.assertEqual( + view_class.call_count.value, + # an initial call + 1 + + # one for each hello world character + len('hello world') + ) + + assert_clean_console(self) + + def _aborted_callback(self, view_class): + """Raising PreventUpdate prevents update and triggering dependencies + """ + self.open('dash/{}/'.format(view_class.dash_name)) + + input_ = self.wait_for_element_by_id('input') + input_.clear() + input_.send_keys('x') + output1 = self.wait_for_element_by_id('output1') + output2 = self.wait_for_element_by_id('output2') + + # callback1 runs twice (initial page load and through send_keys) + self.assertEqual(view_class.callback1_count.value, 2) + + # callback2 is never triggered, even on initial load + self.assertEqual(view_class.callback2_count.value, 0) + + # double check that output1 and output2 children were not updated + self.assertEqual(output1.text, view_class.initial_output) + self.assertEqual(output2.text, view_class.initial_output) + + assert_clean_console(self) + + def _wildcard_data_attributes(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + div = self.wait_for_element_by_id('data-element') + + # React wraps text and numbers with e.g. + # Remove those + comment_regex = '' + + # Somehow the html attributes are unordered. + # Try different combinations (they're all valid html) + permutations = itertools.permutations([ + 'id="inner-element"', + 'data-string="multiple words"', + 'data-number="512"', + 'data-date="%s"' % (view_class.test_date), + 'aria-progress="5"' + ], 5) + passed = False + for permutation in permutations: + actual_cleaned = re.sub(comment_regex, '', + div.get_attribute('innerHTML')) + expected_cleaned = re.sub( + comment_regex, + '', + "
" + .replace('PERMUTE', ' '.join(list(permutation))) + ) + passed = passed or (actual_cleaned == expected_cleaned) + if passed: + break + + if not passed: + raise Exception( + 'HTML does not match\nActual:\n{}\n\nExpected:\n{}'.format( + actual_cleaned, + expected_cleaned + ) + ) + + assert_clean_console(self) + + def _flow_component(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + self.wait_for_element_by_id('waitfor') + + def _no_props_component(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + assert_clean_console(self) + + def _meta_tags(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + metas = view_class.metas + meta = self.driver.find_elements_by_tag_name('meta') + + # -2 for the meta charset and http-equiv. + self.assertEqual(len(metas), len(meta) - 2, 'Not enough meta tags') + + for i in range(2, len(meta)): + meta_tag = meta[i] + meta_info = metas[i - 2] + name = meta_tag.get_attribute('name') + content = meta_tag.get_attribute('content') + self.assertEqual(name, meta_info['name']) + self.assertEqual(content, meta_info['content']) + + def _index_customization(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + header = self.wait_for_element_by_id('custom-header') + footer = self.wait_for_element_by_id('custom-footer') + + self.assertEqual('My custom header', header.text) + self.assertEqual('My custom footer', footer.text) + + add = self.wait_for_element_by_id('add') + + self.assertEqual('Got added', add.text) + + def _assets(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + body = self.driver.find_element_by_tag_name('body') + + body_margin = body.value_of_css_property('margin') + self.assertEqual('0px', body_margin) + + content = self.wait_for_element_by_id('content') + content_padding = content.value_of_css_property('padding') + self.assertEqual('8px', content_padding) + + tested = self.wait_for_element_by_id('tested') + tested = json.loads(tested.text) + + order = ('load_first', 'load_after', 'load_after1', + 'load_after10', 'load_after11', 'load_after2', + 'load_after3', 'load_after4', ) + + self.assertEqual(len(order), len(tested)) + + for i in range(len(tested)): + self.assertEqual(order[i], tested[i]) + + def _invalid_index_string(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + app = view_class().dash + + def will_raise(): + app.index_string = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + +
My custom header
+
+
+
+ + ''' + + with self.assertRaises(Exception) as context: + will_raise() + + app.layout = html.Div() + + exc_msg = str(context.exception) + self.assertTrue('{%app_entry%}' in exc_msg) + self.assertTrue('{%config%}' in exc_msg) + self.assertTrue('{%scripts%}' in exc_msg) + + def _external_files_init(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + js_urls = [x['src'] if isinstance(x, dict) else x for x in view_class.js_files] + css_urls = [x['href'] if isinstance(x, dict) else x for x in view_class.css_files] + + for fmt, url in itertools.chain((("//script[@src='{}']", x) for x in js_urls), + (("//link[@href='{}']", x) for x in css_urls)): + self.driver.find_element_by_xpath(fmt.format(url)) + + # Ensure the button style was overloaded by reset (set to 38px in codepen) + btn = self.driver.find_element_by_id('btn') + btn_height = btn.value_of_css_property('height') + + self.assertEqual('38px', btn_height) + + # ensure ramda was loaded before the assets so they can use it. + lo_test = self.driver.find_element_by_id('ramda-test') + self.assertEqual('Hello World', lo_test.text) + + def _func_layout_accepted(self, view_class): + self.open('dash/{}/'.format(view_class.dash_name)) + + def test_simple_callback(self): + self._simple_callback(dynamic_views.DashSimpleCallback) + self._simple_callback(static_views.DashSimpleCallback) + + def test_wildcard_callback(self): + self._wildcard_callback(dynamic_views.DashWildcardCallback) + self._wildcard_callback(static_views.DashWildcardCallback) + + def test_aborted_callback(self): + self._aborted_callback(dynamic_views.DashAbortedCallback) + self._aborted_callback(static_views.DashAbortedCallback) + + def test_wildcard_data_attributes(self): + self._wildcard_data_attributes(dynamic_views.DashWildcardDataAttributes) + self._wildcard_data_attributes(static_views.DashWildcardDataAttributes) + + def test_flow_component(self): + self._flow_component(dynamic_views.DashFlowComponent) + self._flow_component(static_views.DashFlowComponent) + + def test_no_props_component(self): + self._no_props_component(dynamic_views.DashNoPropsComponent) + self._no_props_component(static_views.DashNoPropsComponent) + + def test_meta_tags(self): + self._meta_tags(dynamic_views.DashMetaTags) + self._meta_tags(static_views.DashMetaTags) + + def test_index_customization(self): + self._index_customization(dynamic_views.DashIndexCustomization) + self._index_customization(static_views.DashIndexCustomization) + + def test_assets(self): + self._assets(dynamic_views.DashAssets) + self._assets(static_views.DashAssets) + + def test_invalid_index_string(self): + self._invalid_index_string(dynamic_views.DashInvalidIndexString) + self._invalid_index_string(static_views.DashInvalidIndexString) + + def test_external_files_init(self): + self._external_files_init(dynamic_views.DashExternalFilesInit) + self._external_files_init(static_views.DashExternalFilesInit) + + def test_func_layout_accepted(self): + self._func_layout_accepted(dynamic_views.DashFuncLayoutAccepted) + self._func_layout_accepted(static_views.DashFuncLayoutAccepted) diff --git a/tests/django_project/tests/utils.py b/tests/django_project/tests/utils.py new file mode 100644 index 0000000..e817ecd --- /dev/null +++ b/tests/django_project/tests/utils.py @@ -0,0 +1,82 @@ +import time + + +TIMEOUT = 5 # Seconds + + +def invincible(func): + def wrap(): + try: + return func() + except: + pass + return wrap + + +class WaitForTimeout(Exception): + """This should only be raised inside the `wait_for` function.""" + pass + + +def wait_for(condition_function, get_message=lambda: '', *args, **kwargs): + """ + Waits for condition_function to return True or raises WaitForTimeout. + :param (function) condition_function: Should return True on success. + :param args: Optional args to pass to condition_function. + :param kwargs: Optional kwargs to pass to condition_function. + if `timeout` is in kwargs, it will be used to override TIMEOUT + :raises: WaitForTimeout If condition_function doesn't return True in time. + Usage: + def get_element(selector): + # some code to get some element or return a `False`-y value. + selector = '.js-plotly-plot' + try: + wait_for(get_element, selector) + except WaitForTimeout: + self.fail('element never appeared...') + plot = get_element(selector) # we know it exists. + """ + def wrapped_condition_function(): + """We wrap this to alter the call base on the closure.""" + if args and kwargs: + return condition_function(*args, **kwargs) + if args: + return condition_function(*args) + if kwargs: + return condition_function(**kwargs) + return condition_function() + + if 'timeout' in kwargs: + timeout = kwargs['timeout'] + del kwargs['timeout'] + else: + timeout = TIMEOUT + + start_time = time.time() + while time.time() < start_time + timeout: + if wrapped_condition_function(): + return True + time.sleep(0.5) + + raise WaitForTimeout(get_message()) + + +def assert_clean_console(TestClass): + def assert_no_console_errors(TestClass): + TestClass.assertEqual( + TestClass.driver.execute_script( + 'return window.tests.console.error.length' + ), + 0 + ) + + def assert_no_console_warnings(TestClass): + TestClass.assertEqual( + TestClass.driver.execute_script( + 'return window.tests.console.warn.length' + ), + 0 + ) + + assert_no_console_warnings(TestClass) + assert_no_console_errors(TestClass) diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..48a500d --- /dev/null +++ b/tests/package.json @@ -0,0 +1,11 @@ +{ + "name": "dash_tests", + "version": "1.0.0", + "description": "Utilities to help with dash tests", + "main": "na", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "chris@plot.ly", + "license": "ISC" +} diff --git a/tests/test_react.py b/tests/test_react.py new file mode 100644 index 0000000..830c723 --- /dev/null +++ b/tests/test_react.py @@ -0,0 +1,497 @@ +import unittest +import json +import pkgutil +import plotly +import dash_core_components as dcc +from dash_html_components import Div +import dash_renderer +import dash + +from dash.dependencies import Event, Input, Output, State +from dash import exceptions + + +def generate_css(css_links): + return '\n'.join([ + ''.format(l) + for l in css_links + ]) + + +def generate_js(js_links): + return '\n'.join([ + ''.format(l) + for l in js_links + ]) + + +class IntegrationTest(unittest.TestCase): + def setUp(self): + self.app = dash.Dash('my-app') + self.app.layout = Div([ + Div('Hello World', id='header', style={'color': 'red'}), + dcc.Input(id='id1', placeholder='Type a value'), + dcc.Input(id='id2', placeholder='Type a value') + ]) + + self.client = self.app.server.test_client() + + self.maxDiff = 100*1000 + + @unittest.skip('') + def test_route_list(self): + urls = [rule.rule for rule in self.app.server.url_map.iter_rules()] + + self.assertEqual( + sorted(urls), + sorted([ + '/interceptor', + '/initialize', + '/dependencies', + '/', + '/component-suites/', + '/static/' + ]) + ) + + @unittest.skip('') + def test_initialize_route(self): + response = self.client.get('/initialize') + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.data), + json.loads( + json.dumps(self.app.layout, cls=plotly.utils.PlotlyJSONEncoder) + ) + ) + + @unittest.skip('') + def test_dependencies_route(self): + self.app.callback('header', ['id1']) + response = self.client.get('/dependencies') + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.data), { + 'header': { + 'state': [{'id': 'id1'}], + 'events': [{'id': 'id1'}] + } + } + ) + + self.app.callback( + 'header', + state=[{'id': 'id1'}], + events=[{'id': 'id1'}]) + response = self.client.get('/dependencies') + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.data), { + 'header': { + 'state': [{'id': 'id1'}], + 'events': [{'id': 'id1'}] + } + } + ) + + state = [ + {'id': 'id1', 'prop': 'value'}, + + # Multiple properties from a single component + {'id': 'id1', 'prop': 'className'}, + + # Nested state + {'id': 'id1', 'prop': ['style', 'color']} + ] + events = [ + {'id': 'id1', 'event': 'click'}, + {'id': 'id1', 'event': 'submit'} + ] + self.app.callback('header', state=state, events=events) + response = self.client.get('/dependencies') + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.data), { + 'header': { + 'state': state, + 'events': events + } + } + ) + + @unittest.skip('') + def test_index_html(self): + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + + @unittest.skip('') + def test_single_observer_returning_a_dict(self): + @self.app.callback('header', ['id1']) + def update_header(input1): + self.assertEqual({'value': 'New Value'}, input1) + new_value = input1['value'] + return { + 'children': new_value, + 'style.color': 'red', + 'className': 'active', + 'width': None + } + + response = self.client.post( + '/interceptor', + headers={ + 'Content-Type': 'application/json' + }, + data=json.dumps({ + # TODO - Why not just `target: id`? + 'target': { + 'props': { + 'id': 'header' + } + }, + 'parents': { + 'id1': { + 'props': { + 'value': 'New Value' + } + } + } + }) + ) + + self.assertEqual( + json.loads(response.data), + { + 'response': { + 'children': 'New Value', + 'props': { + 'id': 'header', + 'style.color': 'red', + 'className': 'active', + 'width': None + } + } + } + ) + + @unittest.skip('') + def test_single_observer_returning_a_component(self): + @self.app.callback('header', ['id1']) + def update_header(input1): + self.assertEqual({'value': 'New Value'}, input1) + return { + 'children': Div('New Component') + } + + response = self.client.post( + '/interceptor', + headers={ + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'target': { + 'props': { + 'id': 'header' + } + }, + 'parents': { + 'id1': { + 'props': { + 'value': 'New Value' + } + } + } + }) + ) + + self.assertEqual( + json.loads(response.data), + { + 'response': { + 'props': { + 'id': 'header' + }, + 'children': { + 'type': 'Div', + 'namespace': 'html_components', + 'props': { + 'children': 'New Component' + } + } + } + } + ) + + @unittest.skip('') + def test_single_observer_updating_component_that_doesnt_exist(self): + # It's possible to register callbacks for components that don't + # exist in the initial layout because users could add them as + # children in response to another callback + @self.app.callback('doesnt-exist-yet', ['id1']) + def update_header(input1): + self.assertEqual({ + 'value': 'New Value' + }, input1) + + new_value = input1['value'] + return { + 'value': new_value + } + + response = self.client.post( + '/interceptor', + headers={ + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'target': { + 'props': { + 'id': 'header' + } + }, + 'parents': { + 'id1': { + 'props': { + 'value': 'New Value' + } + } + } + }) + ) + + self.assertEqual( + json.loads(response.data), + { + 'response': { + 'props': { + 'id': 'doesnt-exit-yet', + 'value': 'New Value' + }, + } + } + ) + + @unittest.skip('') + def test_single_observer_with_multiple_controllers(self): + @self.app.callback('header', ['id1', 'id2']) + def update_header(input1, input2): + self.assertEqual({ + 'value': 'New Value' + }, input1) + self.assertEqual({}, input2) + + new_value = input1['value'] + return { + 'value': new_value + } + + response = self.client.post( + '/interceptor', + headers={ + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'target': { + 'props': { + 'id': 'header' + } + }, + 'parents': { + 'id1': { + 'props': { + 'value': 'New Value' + } + }, + 'id2': { + 'props': {} + } + } + }) + ) + + self.assertEqual( + json.loads(response.data), + { + 'response': { + 'props': { + 'id': 'header', + 'value': 'New Value' + }, + } + } + ) + + def test_serving_scripts(self): + self.app.scripts.config.serve_locally = True + self.app._setup_server() + response = self.client.get( + ('/_dash-component-suites/' + 'dash_renderer/bundle.js?v={}').format(dash_renderer.__version__) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + pkgutil.get_data('dash_renderer', 'bundle.js') + ) + + +class TestCallbacks(unittest.TestCase): + def test_callback_registry(self): + app = dash.Dash('') + input = dcc.Input(id='input') + input._events = ['blur', 'change'] + + app.layout = Div([ + input, + Div(id='output-1'), + Div(id='output-2'), + Div(id='output-3') + ], id='body') + + app.callback( + Output('body', 'children'), + [Input('input', 'value')] + ) + app.callback( + Output('output-1', 'children'), + [Input('input', 'value')] + ) + app.callback( + Output('output-2', 'children'), + [Input('input', 'value')], + state=[State('input', 'value')], + ) + app.callback( + Output('output-3', 'children'), + [Input('input', 'value')], + state=[State('input', 'value')], + events=[Event('input', 'blur')], + ) + + def test_no_layout_exception(self): + app = dash.Dash('') + self.assertRaises( + exceptions.LayoutIsNotDefined, + app.callback, + Output('body', 'children'), + [Input('input', 'value')] + ) + + def test_exception_id_not_in_layout(self): + app = dash.Dash('') + app.layout = Div('', id='test') + self.assertRaises( + exceptions.NonExistantIdException, + app.callback, + Output('output', 'children'), + [Input('input', 'value')] + ) + + def test_exception_prop_not_in_component(self): + app = dash.Dash('') + app.layout = Div([ + dcc.Input(id='input'), + Div(id='output') + ], id='body') + + self.assertRaises( + exceptions.NonExistantPropException, + app.callback, + Output('output', 'non-there'), + [Input('input', 'value')] + ) + + self.assertRaises( + exceptions.NonExistantPropException, + app.callback, + Output('output', 'children'), + [Input('input', 'valuez')] + ) + + self.assertRaises( + exceptions.NonExistantPropException, + app.callback, + Output('body', 'childrenz'), + [Input('input', 'value')] + ) + + def test_exception_event_not_in_component(self): + app = dash.Dash('') + app.layout = Div([ + Div(id='button'), + Div(id='output'), + Div(id='graph-output'), + dcc.Graph(id='graph') + ], id='body') + + for id in ['output', 'body']: + self.assertRaises( + exceptions.NonExistantEventException, + app.callback, + Output(id, 'children'), + events=[Event(id, 'style')] + ) + app.callback( + Output(id, 'children'), + events=[Event(id, 'click')] + ) + + self.assertRaises( + exceptions.NonExistantEventException, + app.callback, + Output('output', 'children'), + events=[Event('graph', 'zoom')] + ) + app.callback( + Output('graph-output', 'children'), + events=[Event('graph', 'click')] + ) + + def test_exception_component_is_not_right_type(self): + app = dash.Dash('') + app.layout = Div([ + dcc.Input(id='input'), + Div(id='output') + ], id='body') + + test_args = [ + ['asdf', ['asdf'], [], []], + [Output('output', 'children'), Input('input', 'value'), [], []], + [Output('output', 'children'), [], State('input', 'value'), []], + [Output('output', 'children'), [], [], Event('input', 'click')], + ] + for args in test_args: + self.assertRaises( + exceptions.IncorrectTypeException, + app.callback, + *args + ) + + def test_suppress_callback_exception(self): + app = dash.Dash('') + app.layout = Div([ + dcc.Input(id='input'), + Div(id='output') + ], id='body') + self.assertRaises( + exceptions.NonExistantIdException, + app.callback, + Output('id-not-there', 'children'), + [Input('input', 'value')] + ) + app.config.supress_callback_exceptions = True + app.callback(Output('id-not-there', 'children'), + [Input('input', 'value')]) + + def test_missing_input_and_events(self): + app = dash.Dash('') + app.layout = Div([ + dcc.Input(id='input') + ], id='body') + self.assertRaises( + exceptions.MissingEventsException, + app.callback, + Output('body', 'children'), + [], + [State('input', 'value')] + ) diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..a25553a --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,164 @@ +import unittest +import warnings +from dash.resources import Scripts, Css +from dash.development.base_component import generate_class + + +def generate_components(): + Div = generate_class('Div', ('children', 'id',), 'dash_html_components') + Span = generate_class('Span', ('children', 'id',), 'dash_html_components') + Input = generate_class( + 'Input', ('children', 'id',), + 'dash_core_components') + return Div, Span, Input + + +def external_url(package_name): + return ( + '//unpkg.com/{}@0.2.9' + '/{}/bundle.js'.format( + package_name.replace('_', '-'), + package_name + ) + ) + + +def rel_path(package_name): + return '{}/bundle.js'.format(package_name) + + +def abs_path(package_name): + return '/Users/chriddyp/{}/bundle.js'.format(package_name) + + +class TestResources(unittest.TestCase): + + def resource_test(self, css_or_js): + Div, Span, Input = generate_components() + + if css_or_js == 'css': + # The CSS URLs and paths will look a little bit differently + # than the JS urls but that doesn't matter for the purposes + # of the test + Div._css_dist = Span._css_dist = [{ + 'external_url': external_url('dash_html_components'), + 'relative_package_path': rel_path('dash_html_components') + }] + + Input._css_dist = [{ + 'external_url': external_url('dash_core_components'), + 'relative_package_path': rel_path('dash_core_components') + }] + + else: + Div._js_dist = Span._js_dist = [{ + 'external_url': external_url('dash_html_components'), + 'relative_package_path': rel_path('dash_html_components') + }] + + Input._js_dist = [{ + 'external_url': external_url('dash_core_components'), + 'relative_package_path': rel_path('dash_core_components') + }] + + layout = Div([None, 'string', Span(), Div(Input())]) + + if css_or_js == 'css': + resources = Css(layout) + else: + resources = Scripts(layout) + + resources._update_layout(layout) + + expected_filtered_external_resources = [ + { + 'external_url': external_url('dash_html_components'), + 'namespace': 'dash_html_components' + }, + { + 'external_url': external_url('dash_core_components'), + 'namespace': 'dash_core_components' + } + ] + expected_filtered_relative_resources = [ + { + 'relative_package_path': rel_path('dash_html_components'), + 'namespace': 'dash_html_components' + }, + { + 'relative_package_path': rel_path('dash_core_components'), + 'namespace': 'dash_core_components' + } + ] + + if css_or_js == 'css': + self.assertEqual( + resources.get_all_css(), + expected_filtered_external_resources + ) + else: + self.assertEqual( + resources.get_all_scripts(), + expected_filtered_external_resources + ) + + resources.config.serve_locally = True + if css_or_js == 'css': + self.assertEqual( + resources.get_all_css(), + expected_filtered_relative_resources + ) + else: + self.assertEqual( + resources.get_all_scripts(), + expected_filtered_relative_resources + ) + + resources.config.serve_locally = False + extra_resource = {'external_url': '//cdn.bootstrap.com/min.css'} + expected_resources = expected_filtered_external_resources + [ + extra_resource + ] + if css_or_js == 'css': + resources.append_css(extra_resource) + self.assertEqual( + resources.get_all_css(), + expected_resources + ) + else: + resources.append_script(extra_resource) + self.assertEqual( + resources.get_all_scripts(), + expected_resources + ) + + resources.config.serve_locally = True + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + if css_or_js == 'css': + self.assertEqual( + resources.get_all_css(), + expected_filtered_relative_resources + ) + assert len(w) == 1 + assert 'A local version of {} is not available'.format( + extra_resource['external_url'] + ) in str(w[-1].message) + + else: + self.assertEqual( + resources.get_all_scripts(), + expected_filtered_relative_resources + ) + assert len(w) == 1 + assert 'A local version of {} is not available'.format( + extra_resource['external_url'] + ) in str(w[-1].message) + + def test_js_resources(self): + # self.resource_test('js') + pass + + def test_css_resources(self): + # self.resource_test('css') + pass diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..dba08c7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,137 @@ +[tox] +envlist = py{27,36,37}, py{27,36,37}dj{109,110,111,200,201} + +[testenv] +deps = + dash_renderer + dash_flow_example==0.0.3 + dash-dangerously-set-inner-html + selenium + tox + plotly>=2.0.8 + pytest-django + flake8 + pylint + + dj109: Django>=1.9,<1.10 + dj110: Django>=1.10,<1.11 + dj111: Django>=1.11,<2.0 + dj200: Django>=2.0,<2.1 + dj201: Django>=2.1,<2.2 +passenv = * + +[py__] +commands = + pip install "dash_core_components>=0.4.0" --no-deps + pip install "dash_html_components>=0.11.0" --no-deps + python --version + python -m unittest tests.development.test_base_component + python -m unittest tests.development.test_component_loader + python -m unittest tests.test_resources + flake8 dash setup.py + +[py__dj] +changedir=tests/django_project +commands = + pip install "dash_core_components>=0.4.0" --no-deps + pip install "dash_html_components>=0.11.0" --no-deps + python --version + python manage.py collectstatic --noinput + pytest + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[testenv:py27] +basepython={env:TOX_PYTHON_27} +commands = + {[py__]commands} + pylint dash setup.py --rcfile={env:PYLINTRC} + +[testenv:py36] +basepython={env:TOX_PYTHON_36} +commands = + {[py__]commands} + pylint dash setup.py --rcfile={env:PYLINTRC} + +[testenv:py37] +basepython={env:TOX_PYTHON_37} +commands = + {[py__]commands} + pylint dash setup.py --rcfile={env:PYLINTRC37} + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[testenv:py27dj109] +basepython={[testenv:py27]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py27dj110] +basepython={[testenv:py27]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py27dj111] +basepython={[testenv:py27]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py27dj200] +basepython={[testenv:py27]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py27dj201] +basepython={[testenv:py27]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[testenv:py36dj109] +basepython={[testenv:py36]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py36dj110] +basepython={[testenv:py36]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py36dj111] +basepython={[testenv:py36]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py36dj200] +basepython={[testenv:py36]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py36dj201] +basepython={[testenv:py36]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[testenv:py37dj109] +basepython={[testenv:py37]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py37dj110] +basepython={[testenv:py37]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py37dj111] +basepython={[testenv:py37]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py37dj200] +basepython={[testenv:py37]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands} + +[testenv:py37dj201] +basepython={[testenv:py37]basepython} +changedir={[py__dj]changedir} +commands={[py__dj]commands}