├── .gitignore ├── .pylint ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── _static │ └── lusmu.css_t ├── _templates │ └── layout.html ├── api │ └── index.rst ├── autobuild.sh ├── conf.py ├── examples │ ├── home-automation.rst │ └── index.rst ├── index.rst └── make.bat ├── lusmu ├── __init__.py ├── core.py ├── examples │ ├── mouse.py │ └── triangle.py ├── tests │ ├── __init__.py │ ├── test_core.py │ ├── test_vector.py │ └── tools.py ├── vector.py └── visualization.py ├── requirements ├── documentation.txt └── testing.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | /lusmu.egg-info 4 | /*.egg 5 | /build 6 | /dist 7 | /docs/_build 8 | /nosetests.xml 9 | /reports 10 | /venv 11 | /coverage.xml 12 | /.coverage -------------------------------------------------------------------------------- /.pylint: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifier separated by comma (,) or put this option 34 | # multiple time (only on the command line, not in the configuration file where 35 | # it should appear only once). 36 | #disable= 37 | 38 | 39 | [REPORTS] 40 | 41 | # Set the output format. Available formats are text, parseable, colorized, msvs 42 | # (visual studio) and html. You can also give a reporter class, eg 43 | # mypackage.mymodule.MyReporterClass. 44 | output-format=text 45 | 46 | # Include message's id in output 47 | include-ids=no 48 | 49 | # Include symbolic ids of messages in output 50 | symbols=no 51 | 52 | # Put messages in a separate file for each module / package specified on the 53 | # command line instead of printing them on stdout. Reports (if any) will be 54 | # written in a file name "pylint_global.[txt|html]". 55 | files-output=no 56 | 57 | # Tells whether to display a full report or only the messages 58 | reports=yes 59 | 60 | # Python expression which should return a note less than 10 (10 is the highest 61 | # note). You have access to the variables errors warning, statement which 62 | # respectively contain the number of errors / warnings messages and the total 63 | # number of statements analyzed. This is used by the global evaluation report 64 | # (RP0004). 65 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 66 | 67 | # Add a comment according to your evaluation note. This is used by the global 68 | # evaluation report (RP0004). 69 | comment=no 70 | 71 | 72 | [SIMILARITIES] 73 | 74 | # Minimum lines number of a similarity. 75 | min-similarity-lines=4 76 | 77 | # Ignore comments when computing similarities. 78 | ignore-comments=yes 79 | 80 | # Ignore docstrings when computing similarities. 81 | ignore-docstrings=yes 82 | 83 | # Ignore imports when computing similarities. 84 | ignore-imports=no 85 | 86 | 87 | [FORMAT] 88 | 89 | # Maximum number of characters on a single line. 90 | max-line-length=80 91 | 92 | # Maximum number of lines in a module 93 | max-module-lines=1000 94 | 95 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 96 | # tab). 97 | indent-string=' ' 98 | 99 | 100 | [TYPECHECK] 101 | 102 | # Tells whether missing members accessed in mixin class should be ignored. A 103 | # mixin class is detected if its name ends with "mixin" (case insensitive). 104 | ignore-mixin-members=yes 105 | 106 | # List of classes names for which member attributes should not be checked 107 | # (useful for classes with attributes dynamically set). 108 | ignored-classes=SQLObject 109 | 110 | # When zope mode is activated, add a predefined set of Zope acquired attributes 111 | # to generated-members. 112 | zope=no 113 | 114 | # List of members which are set dynamically and missed by pylint inference 115 | # system, and so shouldn't trigger E0201 when accessed. Python regular 116 | # expressions are accepted. 117 | generated-members=REQUEST,acl_users,aq_parent 118 | 119 | 120 | [BASIC] 121 | 122 | # Required attributes for module, separated by a comma 123 | required-attributes= 124 | 125 | # List of builtins function names that should not be used, separated by a comma 126 | bad-functions=map,filter,apply,input 127 | 128 | # Regular expression which should only match correct module names 129 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 130 | 131 | # Regular expression which should only match correct module level names 132 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 133 | 134 | # Regular expression which should only match correct class names 135 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 136 | 137 | # Regular expression which should only match correct function names 138 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 139 | 140 | # Regular expression which should only match correct method names 141 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 142 | 143 | # Regular expression which should only match correct instance attribute names 144 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 145 | 146 | # Regular expression which should only match correct argument names 147 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 148 | 149 | # Regular expression which should only match correct variable names 150 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 151 | 152 | # Regular expression which should only match correct list comprehension / 153 | # generator expression variable names 154 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 155 | 156 | # Good variable names which should always be accepted, separated by a comma 157 | good-names=i,j,k,ex,Run,_ 158 | 159 | # Bad variable names which should always be refused, separated by a comma 160 | bad-names=foo,bar,baz,toto,tutu,tata 161 | 162 | # Regular expression which should only match functions or classes name which do 163 | # not require a docstring 164 | no-docstring-rgx=__.*__ 165 | 166 | 167 | [VARIABLES] 168 | 169 | # Tells whether we should check for unused import in __init__ files. 170 | init-import=no 171 | 172 | # A regular expression matching the beginning of the name of dummy variables 173 | # (i.e. not used). 174 | dummy-variables-rgx=_|dummy 175 | 176 | # List of additional names supposed to be defined in builtins. Remember that 177 | # you should avoid to define new builtins when possible. 178 | additional-builtins= 179 | 180 | 181 | [MISCELLANEOUS] 182 | 183 | # List of note tags to take in consideration, separated by a comma. 184 | notes=FIXME,XXX,TODO 185 | 186 | 187 | [IMPORTS] 188 | 189 | # Deprecated modules which should not be used, separated by a comma 190 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec 191 | 192 | # Create a graph of every (i.e. internal and external) dependencies in the 193 | # given file (report RP0402 must not be disabled) 194 | import-graph= 195 | 196 | # Create a graph of external dependencies in the given file (report RP0402 must 197 | # not be disabled) 198 | ext-import-graph= 199 | 200 | # Create a graph of internal dependencies in the given file (report RP0402 must 201 | # not be disabled) 202 | int-import-graph= 203 | 204 | 205 | [CLASSES] 206 | 207 | # List of interface methods to ignore, separated by a comma. This is used for 208 | # instance to not check methods defines in Zope's Interface base class. 209 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 210 | 211 | # List of method names used to declare (i.e. assign) instance attributes. 212 | defining-attr-methods=__init__,__new__,setUp 213 | 214 | # List of valid names for the first argument in a class method. 215 | valid-classmethod-first-arg=cls 216 | 217 | # List of valid names for the first argument in a metaclass class method. 218 | valid-metaclass-classmethod-first-arg=mcs 219 | 220 | 221 | [DESIGN] 222 | 223 | # Maximum number of arguments for function / method 224 | max-args=5 225 | 226 | # Argument names that match this expression will be ignored. Default to name 227 | # with leading underscore 228 | ignored-argument-names=_.* 229 | 230 | # Maximum number of locals for function / method body 231 | max-locals=15 232 | 233 | # Maximum number of return / yield for function / method body 234 | max-returns=6 235 | 236 | # Maximum number of branch for function / method body 237 | max-branchs=12 238 | 239 | # Maximum number of statements in function / method body 240 | max-statements=50 241 | 242 | # Maximum number of parents for a class (see R0901). 243 | max-parents=7 244 | 245 | # Maximum number of attributes for a class (see R0902). 246 | max-attributes=7 247 | 248 | # Minimum number of public methods for a class (see R0903). 249 | min-public-methods=2 250 | 251 | # Maximum number of public methods for a class (see R0904). 252 | max-public-methods=20 253 | 254 | 255 | [EXCEPTIONS] 256 | 257 | # Exceptions that will emit a warning when being caught. Defaults to 258 | # "Exception" 259 | overgeneral-exceptions=Exception 260 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Eniram Ltd 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the 14 | distribution. 15 | 16 | 3. Neither the name of Eniram Ltd nor the names of its contributors 17 | may be used to endorse or promote products derived from this 18 | software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | :authors: Antti Kaihola 2 | :organization: Eniram Ltd 3 | :copyright: 2013 Eniram Ltd. See the LICENSE file at the top-level 4 | directory of this distribution and at 5 | https://github.com/akaihola/lusmu/blob/master/LICENSE 6 | 7 | Documentation_ | `Source code`_ | PyPI_ | Download_ | License_ 8 | 9 | Lusmu – a dataflow/reactive programming library for Python 10 | ========================================================== 11 | 12 | Lusmu is a Python library for `reactive programming`_ (a form of 13 | `dataflow programming`_). Operations on data are done using a 14 | `directed graph`_ which consists of input nodes and calculation nodes. 15 | 16 | Lusmu uses the `invalidate/lazy-revalidate`_ evaluation model: reading 17 | the value of a node triggers its calculation action and reads the 18 | values of its inputs. Thus, only required calculations are executed. 19 | 20 | A minimal example 21 | ----------------- 22 | 23 | :: 24 | 25 | from lusmu.core import Input, Node, update_inputs 26 | 27 | root = Input() 28 | square = Node(action=lambda x: x ** 2, 29 | inputs=Node.inputs(root)) 30 | 31 | update_inputs([(root, 5)]) 32 | print square.value 33 | 34 | The output:: 35 | 36 | 25 37 | 38 | See mouse.py_ and triangle.py_ for more comples examples. 39 | 40 | .. _Documentation: http://lusmu.readthedocs.org/ 41 | .. _`Source code`: https://github.com/akaihola/lusmu 42 | .. _PyPI: https://pypi.python.org/pypi/lusmu 43 | .. _Download: https://pypi.python.org/packages/source/l/lusmu/ 44 | .. _License: https://github.com/akaihola/lusmu/blob/master/LICENSE 45 | .. _`reactive programming`: https://en.wikipedia.org/wiki/Reactive_programming 46 | .. _`dataflow programming`: https://en.wikipedia.org/wiki/Dataflow_programming 47 | .. _`directed graph`: https://en.wikipedia.org/wiki/Directed_graph 48 | .. _`invalidate/lazy-revalidate`: https://en.wikipedia.org/wiki/Reactive_programming#Evaluation_models_of_reactive_programming 49 | .. _`mouse.py`: https://github.com/akaihola/lusmu/blob/master/lusmu/examples/mouse.py 50 | .. _`triangle.py`: https://github.com/akaihola/lusmu/blob/master/lusmu/examples/triangle.py 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Lusmu.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Lusmu.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Lusmu" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Lusmu" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/lusmu.css_t: -------------------------------------------------------------------------------- 1 | div.section > dl { 2 | margin-top: 20px; 3 | border-top: solid 1px #d0d0d0; 4 | padding-top: 20px; 5 | } -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!cloud/layout.html" %} 2 | 3 | {% set css_files = css_files + ["_static/lusmu.css"] %} 4 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | The API 2 | ======= 3 | 4 | .. automodule:: lusmu.core 5 | :members: 6 | 7 | .. automodule:: lusmu.vector 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/autobuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build the documentation automatically when any file changes. 4 | # 5 | # Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 6 | # this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 7 | # 8 | # Usage: ./autobuild.sh 9 | # 10 | # On Debian/Ubuntu, this requires: 11 | # sudo apt-get install inotify-tools 12 | 13 | ROOT=`cd $(dirname $0) ; pwd` 14 | cd $ROOT 15 | make clean 16 | make SPHINXOPTS=-q html 17 | while (true); do 18 | echo 19 | echo ====================================================== 20 | echo Waiting for changes in documentation source files... 21 | echo ====================================================== 22 | echo 23 | 24 | inotifywait \ 25 | -q -r -e modify,move,create,delete \ 26 | --exclude '\.?#.*|.*_flymake.*|.*\.doctree' \ 27 | . ../lusmu 28 | 29 | make SPHINXOPTS=-q html 30 | 31 | done 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Lusmu documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Sep 9 13:11:12 2013. 5 | # 6 | # Modifications copyright 2013 Eniram Ltd. See the LICENSE file at the 7 | # top-level directory of this distribution and at 8 | # https://github.com/akaihola/lusmu/blob/master/LICENSE 9 | # 10 | # This file is execfile()d with the current directory set to its containing dir. 11 | # 12 | # Note that not all possible configuration values are present in this 13 | # autogenerated file. 14 | # 15 | # All configuration values have a default; values that are commented out 16 | # serve to show the default. 17 | 18 | import os 19 | import sys 20 | import cloud_sptheme as csp 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | #sys.path.insert(0, os.path.abspath('.')) 26 | 27 | # -- General configuration ----------------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be extensions 33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.graphviz', 37 | 'sphinx.ext.intersphinx', 38 | 'sphinx.ext.todo', 39 | 'sphinx.ext.viewcode'] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix of source filenames. 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | #source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = u'Lusmu' 55 | copyright = u'2013, Eniram Ltd' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '0.1' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '0.1' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | #language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | #today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ['_build'] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all documents. 81 | #default_role = None 82 | 83 | # If true, '()' will be appended to :func: etc. cross-reference text. 84 | #add_function_parentheses = True 85 | 86 | # If true, the current module name will be prepended to all description 87 | # unit titles (such as .. function::). 88 | #add_module_names = True 89 | 90 | # If true, sectionauthor and moduleauthor directives will be shown in the 91 | # output. They are ignored by default. 92 | #show_authors = False 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = 'sphinx' 96 | 97 | # A list of ignored prefixes for module index sorting. 98 | #modindex_common_prefix = [] 99 | 100 | # If true, keep warnings as "system message" paragraphs in the built documents. 101 | #keep_warnings = False 102 | 103 | 104 | # -- Options for HTML output --------------------------------------------------- 105 | 106 | # The theme to use for HTML and HTML Help pages. See the documentation for 107 | # a list of builtin themes. 108 | html_theme = 'cloud' 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | html_theme_options = {'roottarget': 'index'} 114 | 115 | # Add any paths that contain custom themes here, relative to this directory. 116 | html_theme_path = ['_templates', csp.get_theme_dir()] 117 | 118 | # The name for this set of Sphinx documents. If None, it defaults to 119 | # " v documentation". 120 | #html_title = None 121 | 122 | # A shorter title for the navigation bar. Default is the same as html_title. 123 | #html_short_title = None 124 | 125 | # The name of an image file (relative to this directory) to place at the top 126 | # of the sidebar. 127 | #html_logo = None 128 | 129 | # The name of an image file (within the static path) to use as favicon of the 130 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 131 | # pixels large. 132 | #html_favicon = None 133 | 134 | # Add any paths that contain custom static files (such as style sheets) here, 135 | # relative to this directory. They are copied after the builtin static files, 136 | # so a file named "default.css" will overwrite the builtin "default.css". 137 | html_static_path = ['_static'] 138 | 139 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 140 | # using the given strftime format. 141 | #html_last_updated_fmt = '%b %d, %Y' 142 | 143 | # If true, SmartyPants will be used to convert quotes and dashes to 144 | # typographically correct entities. 145 | #html_use_smartypants = True 146 | 147 | # Custom sidebar templates, maps document names to template names. 148 | #html_sidebars = {} 149 | 150 | # Additional templates that should be rendered to pages, maps page names to 151 | # template names. 152 | #html_additional_pages = {} 153 | 154 | # If false, no module index is generated. 155 | #html_domain_indices = True 156 | 157 | # If false, no index is generated. 158 | #html_use_index = True 159 | 160 | # If true, the index is split into individual pages for each letter. 161 | #html_split_index = False 162 | 163 | # If true, links to the reST sources are added to the pages. 164 | #html_show_sourcelink = True 165 | 166 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 167 | #html_show_sphinx = True 168 | 169 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 170 | #html_show_copyright = True 171 | 172 | # If true, an OpenSearch description file will be output, and all pages will 173 | # contain a tag referring to it. The value of this option must be the 174 | # base URL from which the finished HTML is served. 175 | #html_use_opensearch = '' 176 | 177 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 178 | #html_file_suffix = None 179 | 180 | # Output file base name for HTML help builder. 181 | htmlhelp_basename = 'Lusmudoc' 182 | 183 | 184 | # -- Options for LaTeX output -------------------------------------------------- 185 | 186 | latex_elements = { 187 | # The paper size ('letterpaper' or 'a4paper'). 188 | #'papersize': 'letterpaper', 189 | 190 | # The font size ('10pt', '11pt' or '12pt'). 191 | #'pointsize': '10pt', 192 | 193 | # Additional stuff for the LaTeX preamble. 194 | #'preamble': '', 195 | } 196 | 197 | # Grouping the document tree into LaTeX files. List of tuples 198 | # (source start file, target name, title, author, documentclass [howto/manual]). 199 | latex_documents = [ 200 | ('index', 'Lusmu.tex', u'Lusmu Documentation', 201 | u'Eniram Ltd', 'manual'), 202 | ] 203 | 204 | # The name of an image file (relative to this directory) to place at the top of 205 | # the title page. 206 | #latex_logo = None 207 | 208 | # For "manual" documents, if this is true, then toplevel headings are parts, 209 | # not chapters. 210 | #latex_use_parts = False 211 | 212 | # If true, show page references after internal links. 213 | #latex_show_pagerefs = False 214 | 215 | # If true, show URL addresses after external links. 216 | #latex_show_urls = False 217 | 218 | # Documents to append as an appendix to all manuals. 219 | #latex_appendices = [] 220 | 221 | # If false, no module index is generated. 222 | #latex_domain_indices = True 223 | 224 | 225 | # -- Options for manual page output -------------------------------------------- 226 | 227 | # One entry per manual page. List of tuples 228 | # (source start file, name, description, authors, manual section). 229 | man_pages = [ 230 | ('index', 'lusmu', u'Lusmu Documentation', 231 | [u'Eniram Ltd'], 1) 232 | ] 233 | 234 | # If true, show URL addresses after external links. 235 | #man_show_urls = False 236 | 237 | 238 | # -- Options for Texinfo output ------------------------------------------------ 239 | 240 | # Grouping the document tree into Texinfo files. List of tuples 241 | # (source start file, target name, title, author, 242 | # dir menu entry, description, category) 243 | texinfo_documents = [ 244 | ('index', 'Lusmu', u'Lusmu Documentation', 245 | u'Eniram Ltd', 'Lusmu', 'One line description of project.', 246 | 'Miscellaneous'), 247 | ] 248 | 249 | # Documents to append as an appendix to all manuals. 250 | #texinfo_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | #texinfo_domain_indices = True 254 | 255 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 256 | #texinfo_show_urls = 'footnote' 257 | 258 | # If true, do not generate a @detailmenu in the "Top" node's menu. 259 | #texinfo_no_detailmenu = False 260 | 261 | 262 | # Example configuration for intersphinx: refer to the Python standard library. 263 | intersphinx_mapping = {'http://docs.python.org/': None} 264 | -------------------------------------------------------------------------------- /docs/examples/home-automation.rst: -------------------------------------------------------------------------------- 1 | Home automation example 2 | ======================= 3 | 4 | This is an example of using reactive programming and the Lusmu library 5 | in a home automation setting. 6 | 7 | First, import the Lusmu :class:`~lusmu.core.Input` 8 | and :class:`~lusmu.core.Node` classes, 9 | the :func:`~lusmu.core.update_inputs` function for inserting input values 10 | and the Python :mod:`math` package:: 11 | 12 | from lusmu.core import Input, Node, update_inputs 13 | import math 14 | 15 | Them define the action functions 16 | to be used in the home automation system. 17 | 18 | .. note:: Actions with positional arguments 19 | receive them as separate arguments, not as a list. 20 | This is why we need to wrap Python's :func:`sum` function. 21 | 22 | :: 23 | 24 | def avg(*args): 25 | return sum(args) / len(args) 26 | 27 | def sum_(*args): 28 | return sum(args) 29 | 30 | def inverse(max_value): 31 | def _inverse(value): 32 | return max_value - value 33 | return _inverse 34 | 35 | The output from two temperature sensors are averaged, 36 | and a lower limit of 20.0 degrees is used to switch the heater off: 37 | 38 | .. graphviz:: 39 | 40 | digraph temperature { 41 | temperature_1 [shape=diamond]; 42 | temperature_2 [shape=diamond]; 43 | temperature_1 -> temperature_avg; 44 | temperature_2 -> temperature_avg; 45 | temperature_avg -> temperature_threshold; 46 | temperature_threshold -> heater; 47 | } 48 | 49 | :: 50 | 51 | temperature_1 = Input() 52 | temperature_2 = Input() 53 | temperature_avg = Node(action=avg, 54 | inputs=Node.inputs(temperature_1, temperature_2)) 55 | temperature_threshold = Node(action=lambda temperature: temperature > 20.0, 56 | inputs=Node.inputs(temperature_avg)) 57 | 58 | def switch_heater(should_be_off): 59 | print 'Heater {}'.format('off' if should_be_off else 'on') 60 | 61 | heater = Node(action=switch_heater, 62 | inputs=Node.inputs(temperature_threshold), 63 | triggered=True) 64 | 65 | The lights are adjusted according to brightness sensors in the windows: 66 | 67 | .. graphviz:: 68 | 69 | digraph brightness { 70 | brightness_1 [shape=diamond]; 71 | brightness_2 [shape=diamond]; 72 | brightness_1 -> brightness_sum; 73 | brightness_2 -> brightness_sum; 74 | brightness_sum -> brightness_inverse; 75 | brightness_inverse -> lamp_power; 76 | } 77 | 78 | :: 79 | 80 | brightness_1 = Input() 81 | brightness_2 = Input() 82 | brightness_sum = Node(action=sum_, 83 | inputs=Node.inputs(brightness_1, brightness_2)) 84 | brightness_inverse = Node(action=inverse(510), 85 | inputs=Node.inputs(brightness_sum)) 86 | 87 | def set_lamp_power(power): 88 | print 'Lamp power {:.2f}'.format(power) 89 | 90 | lamp_power = Node(action=set_lamp_power, 91 | inputs=Node.inputs(brightness_inverse), 92 | triggered=True) 93 | 94 | Based on output of the humidity sensor, 95 | the relative humidity is calculated: 96 | 97 | .. graphviz:: 98 | 99 | digraph humidity { 100 | humidity [shape=diamond]; 101 | humidity -> humidity_normalized; 102 | } 103 | 104 | :: 105 | 106 | humidity = Input() 107 | humidity_normalized = Node(action=lambda sensor_value: 100.0 * (1.0 - math.log(sensor_value, 255)), 108 | inputs=Node.inputs(humidity)) 109 | 110 | Initially the value of all nodes is undefined. 111 | The :obj:`lusmu.core.DIRTY` special object is used 112 | to denote an undefined value. 113 | The private :attr:`~lusmu.core.Node._value` attribute 114 | can be inspected to see the cached value of the node 115 | without triggering lazy evaluation:: 116 | 117 | >>> temperature_avg._value 118 | 119 | 120 | Values are fed into input nodes 121 | using the :func:`~lusmu.core.update_inputs` function:: 122 | 123 | >>> update_inputs([(temperature_1, 25.0), 124 | ... (temperature_2, 22.5), 125 | ... (brightness_1, 100), 126 | ... (brightness_2, 110), 127 | ... (humidity, 50)]) 128 | Heater off 129 | Lamp power 300.0 130 | 131 | Since the heater and lamp control nodes 132 | are defined as auto-calculated (``triggered=True``), 133 | all nodes on those dependency paths are evaluated 134 | when values of nodes are updated:: 135 | 136 | >>> temperature_avg._value 137 | 23.75 138 | >>> brightness_sum._value 139 | 210 140 | 141 | On the other hand, the relative humidity value is not auto-calculated:: 142 | 143 | >>> humidity_normalized._value 144 | 145 | 146 | The dependency path from the input node to the requested humidity value 147 | is only evaluated when needed. 148 | The :attr:`lusmu.core.Node.value` property triggers evaluation:: 149 | 150 | >>> humidity_normalized.value 151 | 29.40196809721851 152 | 153 | Unchanged values don't trigger evaluation: 154 | 155 | >>> update_inputs([(temperature_1, 25.0), 156 | ... (temperature_2, 22.5)}) 157 | 158 | Changing the values does:: 159 | 160 | >>> update_inputs([(temperature_1, 21.0), 161 | ... (temperature_2, 18.5)]) 162 | Heater on 163 | Lamp power 405.00 164 | -------------------------------------------------------------------------------- /docs/examples/index.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | .. toctree:: 5 | home-automation 6 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Lusmu documentation master file, created by 2 | sphinx-quickstart on Mon Sep 9 13:11:12 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. _lusmu: 7 | 8 | Lusmu – a reactive programming library for Python 9 | ================================================= 10 | 11 | Contents: 12 | 13 | .. toctree:: 14 | api/index 15 | examples/index 16 | :maxdepth: 2 17 | 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Lusmu.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Lusmu.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /lusmu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaihola/lusmu/b20c1a1aa721717862aefeccc19542e0ccea0f0d/lusmu/__init__.py -------------------------------------------------------------------------------- /lusmu/core.py: -------------------------------------------------------------------------------- 1 | """Core functionality of the lusmu library 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | 8 | # pylint: disable=W0212 9 | # Allow access to protected members of client classes 10 | # pylint: disable=W0142 11 | # Allow * and ** magic 12 | 13 | from collections import defaultdict 14 | from functools import total_ordering 15 | import itertools 16 | import logging 17 | import sys 18 | 19 | 20 | LOG = logging.getLogger('lusmu.base') 21 | 22 | # When developing, change this to True in order to verify the type consistency 23 | # of Lusmu graphs. 24 | VERIFY_OUTPUT_TYPES = False 25 | 26 | 27 | if sys.version_info[0] == 2: 28 | def items(dictionary): 29 | """Return a set-like object, a view on key/value pairs of a dict""" 30 | return dictionary.viewitems() 31 | 32 | def values(dictionary): 33 | """Return an object providing a view on the values of a dictionary""" 34 | return dictionary.viewvalues() 35 | 36 | def get_func_name(function, default=None): 37 | """Return the name of the function, falling back to a default""" 38 | return getattr(function, 'func_name', default) 39 | else: 40 | def items(dictionary): 41 | """Return a set-like object, a view on key/value pairs of a dict""" 42 | return dictionary.items() 43 | 44 | def values(dictionary): 45 | """Return an object providing a view on the values of a dictionary""" 46 | return dictionary.values() 47 | 48 | def get_func_name(function, default=None): 49 | """Return the name of the function, falling back to a default""" 50 | return getattr(function, '__name__', default) 51 | 52 | 53 | _TRIGGERED_CACHE = {} 54 | 55 | 56 | class _DIRTY(object): 57 | """Class definition for the dirty node special value""" 58 | 59 | def __str__(self): 60 | return 'DIRTY' 61 | 62 | def __repr__(self): 63 | return '' 64 | 65 | def __eq__(self, other): 66 | return self.__class__ == other.__class__ 67 | 68 | 69 | DIRTY = _DIRTY() 70 | 71 | 72 | class BaseNode(object): 73 | """Base class for Inputs and Nodes""" 74 | 75 | _name_counters = defaultdict(int) 76 | 77 | def __init__(self, name=None, value=DIRTY): 78 | self.name = name or self._generate_name() 79 | self._value = value 80 | self._dependents = set() 81 | 82 | def _connect(self, dependent): 83 | """Set the given Node as a dependent of this Node or Input 84 | 85 | Immediately paints the new dependent Node dirty if this Node has 86 | already been evaluated or if a value has already been set for this 87 | Input. 88 | 89 | Connecting Nodes always invalidates the triggered Nodes cache. 90 | 91 | """ 92 | if dependent not in self._dependents: 93 | self._dependents.add(dependent) 94 | if self._value is not DIRTY: 95 | dependent._set_value(DIRTY, get_triggered=False) 96 | _TRIGGERED_CACHE.clear() 97 | 98 | def _disconnect(self, dependent): 99 | """Remove given Node from the set of dependents of this Node or Input 100 | 101 | Immediately paints the new dependent Node dirty if this Node has 102 | previously been evaluated or if a value has previously been set for 103 | this Input. 104 | 105 | Disconnecting Nodes always invalidates the triggered nodes cache. 106 | 107 | """ 108 | if dependent in self._dependents: 109 | self._dependents.remove(dependent) 110 | if self._value is not DIRTY: 111 | dependent._set_value(DIRTY, get_triggered=False) 112 | _TRIGGERED_CACHE.clear() 113 | 114 | def _set_value(self, value, get_triggered=True): 115 | """Set a new value for this Node or Input 116 | 117 | If this caused the value to change, paints dependent Nodes dirty and 118 | returns the set of those dependent Nodes which are marked "triggered" 119 | and should be re-evaluated. 120 | 121 | When called by ``set_value`` from external code, the ``get_triggered`` 122 | argument must be ``True`` so the return value is cached. Internal 123 | calls set ``get_triggered=False`` so memory isn't wasted for caching the 124 | triggered dependents of intermediate Nodes. 125 | 126 | This private method can be used as a debugging tool to set values of 127 | non-input Nodes. 128 | 129 | """ 130 | # test if neither, one of or both the old and the new value are DIRTY 131 | dirty_count = len([v for v in value, self._value if v is DIRTY]) 132 | if dirty_count == 2: 133 | # both DIRTY, no need to touch anything 134 | return set() 135 | if dirty_count == 0 and self._value_eq(value): 136 | # both non-DIRTY but equal, no need to touch anything 137 | return set() 138 | # either one is DIRTY, or values aren't equal, update the value and 139 | # paint the dependent Nodes dirty 140 | self._value = value 141 | self._set_dependents_dirty() 142 | if get_triggered: 143 | return self._get_triggered_dependents() 144 | 145 | def _value_eq(self, other_value): 146 | return self._value == other_value 147 | 148 | def get_value(self): 149 | """Return the value of the object""" 150 | raise NotImplementedError('The get_value() method must be defined ' 151 | 'for subclasses of BaseNode') 152 | 153 | def _get_triggered_dependents(self, make_cache=True): 154 | """Return the set of triggered dependent Nodes 155 | 156 | The set includes Nodes which are marked as triggered and are included 157 | in the dependent chain from this Node or Input. 158 | 159 | The result is cached for the Node or Input if ``make_cache == True``, 160 | but caching is suppressed for recursively walked dependent Nodes. This 161 | way we only use cache memory only for Nodes and Inputs whose triggered 162 | dependents are queried from external code. 163 | 164 | """ 165 | if self in _TRIGGERED_CACHE: 166 | return _TRIGGERED_CACHE[self] 167 | triggered = set() 168 | for dependent in self._dependents: 169 | if dependent.triggered: 170 | triggered.add(dependent) 171 | triggered |= dependent._get_triggered_dependents(make_cache=False) 172 | if make_cache: 173 | _TRIGGERED_CACHE[self] = triggered 174 | return triggered 175 | 176 | def _set_dependents_dirty(self): 177 | """Paint all dependent Nodes dirty 178 | 179 | Paints direct dependent Nodes dirty, which causes recursive painting 180 | for the whole dependent Nodes tree. 181 | 182 | """ 183 | for dependent in self._dependents: 184 | dependent._set_value(DIRTY, get_triggered=False) 185 | 186 | def _generate_name(self): 187 | """Generate a unique name for this Node or Input object 188 | 189 | The name includes: 190 | 191 | * the name of the class 192 | * an auto-incremented number 193 | 194 | """ 195 | counters = self._name_counters 196 | template = '{class_name}-{counter}' 197 | counters[self.__class__] += 1 198 | return template.format(class_name=self.__class__.__name__, 199 | counter=counters[self.__class__]) 200 | 201 | def __unicode__(self): 202 | return unicode(self.get_value()) 203 | 204 | def __repr__(self): 205 | return ('<{self.__class__.__name__} {self.name}: {self._value}>' 206 | .format(self=self)) 207 | 208 | 209 | class Input(BaseNode): 210 | """The input node class for reactive programming 211 | 212 | Constructor arguments 213 | --------------------- 214 | 215 | name (optional): string 216 | The internal name of the Input. Used in the 217 | ``__repr__`` of the object. If omitted, a name is 218 | automatically generated. 219 | 220 | value (optional): 221 | The initial value for the Input. 222 | 223 | Examples of Inputs:: 224 | 225 | >>> input_1 = Input() # no name, no default value 226 | >>> input_2 = Input(value=10.0) # input with a default value 227 | >>> exponent = Input(name='exponent') # named input 228 | >>> sensor = Input(name='sensor', value=-5.3) # named, with default 229 | 230 | """ 231 | def get_value(self): 232 | """Return the value of the Input""" 233 | return self._value 234 | 235 | def set_value(self, new_value): 236 | """Set a new value for an Input 237 | 238 | If this caused the value to change, paints dependent Nodes dirty and 239 | returns the set of those dependent Nodes which are marked "triggered" 240 | and should be re-evaluated. 241 | 242 | """ 243 | return self._set_value(new_value, get_triggered=True) 244 | 245 | value = property(get_value, set_value) 246 | 247 | 248 | @total_ordering 249 | class Node(BaseNode): 250 | """The Node class for reactive programming 251 | 252 | Constructor arguments 253 | --------------------- 254 | 255 | name (optional): string 256 | The internal name of the Node. Used in the ``__repr__`` of the 257 | object. If omitted, a name is automatically generated. 258 | 259 | action: callable(*positional_inputs, **keyword_inputs) 260 | The function for calculating the value of a calculated node. 261 | Values from inputs are provided in positional and keyword arguments 262 | as defined in the ``inputs=`` argument. 263 | 264 | inputs (optional): ((Input/Node, ...), {key: Input/Node, ...}) 265 | The Nodes and Inputs whose values are used as inputs for the 266 | action. This argument can be created with ``Node.inputs()`` which 267 | provides a cleaner syntax. 268 | 269 | triggered: boolean (default=False) 270 | ``True`` is this Node shoud be automatically evaluated when any of 271 | its dependency Nodes or Inputs change value 272 | 273 | Examples of Nodes:: 274 | 275 | >>> input_1, input_2, exponent = [Input() for i in range(3)] 276 | >>> # sum Node with two positional inputs 277 | >>> sum_node = Node(action=lambda *args: sum(args), 278 | ... inputs=Node.inputs(input_1, input_2)) 279 | >>> # triggered (auto-calculated) Node with two keyword inputs 280 | >>> triggered_node = Node( 281 | ... action=lambda a, x: a ** x, 282 | ... inputs=Node.inputs(a=input_1, x=exponent), 283 | ... triggered=True) 284 | 285 | """ 286 | def __init__(self, 287 | name=None, 288 | action=None, 289 | inputs=((), None), 290 | triggered=False): 291 | self._action = action # must be set before generating name 292 | super(Node, self).__init__(name, value=DIRTY) 293 | self.triggered = triggered 294 | self._positional_inputs = () 295 | self._keyword_inputs = {} 296 | self.set_inputs(*inputs[0], **inputs[1] or {}) 297 | self._set_dependents_dirty() 298 | 299 | def _evaluate(self): 300 | """Calculate the value for the Node 301 | 302 | Calls the action of the Node using values from the inputs of the Node. 303 | Returns the result of the action function. 304 | 305 | This function can also be overridden in subclasses if a class-based 306 | approach to creating Node actions is preferred. 307 | 308 | """ 309 | if not self._action: 310 | raise NotImplementedError('You must define the action= argument ' 311 | 'when instantiating the Node') 312 | positional_values = [i.get_value() 313 | for i in self._positional_inputs] 314 | keyword_values = {name: i.get_value() 315 | for name, i in items(self._keyword_inputs)} 316 | value = self._action(*positional_values, **keyword_values) 317 | if ((VERIFY_OUTPUT_TYPES 318 | and getattr(self._action, 'output_type', None) is not None)): 319 | # Output type checking has been enabled, and the node's action 320 | # does specify the expected output type. Check that the calculated 321 | # value matches that type. 322 | self._verify_output_type(value) 323 | return value 324 | 325 | @staticmethod 326 | def inputs(*args, **kwargs): 327 | """Construct a value for the inputs= kwarg of the constructor 328 | 329 | Allows writing this:: 330 | 331 | >>> inputs = [Input() for i in range(4)] 332 | >>> node = Node(inputs=Node.inputs(inputs[0], inputs[1], 333 | ... kw1=inputs[2], kw2=inputs[3])) 334 | 335 | instead of this:: 336 | 337 | >>> node = Node(inputs=([inputs[0], inputs[1]], 338 | ... {'kw1': inputs[2], 'kw2': inputs[3]})) 339 | 340 | """ 341 | return args, kwargs 342 | 343 | def _verify_output_type(self, value): 344 | """Assert that the given value matches the action's output type 345 | 346 | This check should be run only in development if the developer wants to 347 | ensure the consistency of a graph's types. 348 | 349 | This method may only be called if the node's action has a non-None 350 | ``output_type`` attribute. 351 | 352 | Arguments 353 | --------- 354 | value: The value whose type is to be checked 355 | 356 | Raises 357 | ------ 358 | TypeError: The value doesn't match the desired output type of the 359 | node's action 360 | 361 | """ 362 | if not isinstance(value, self._action.output_type): 363 | raise TypeError( 364 | "The output value type {value_type!r} for [{self.name}]\n" 365 | "doesn't match the expected type " 366 | '{self._action.output_type.__name__!r} for action ' 367 | '"{self._action.name}".' 368 | .format(value_type=type(value).__name__, self=self)) 369 | 370 | def set_inputs(self, *args, **kwargs): 371 | """Replace current positional and keyword inputs""" 372 | for inp in self._iterate_inputs(): 373 | inp._disconnect(self) 374 | self._positional_inputs = args 375 | self._keyword_inputs = kwargs 376 | for inp in self._iterate_inputs(): 377 | inp._connect(self) 378 | 379 | def get_value(self): 380 | """Return Node value, evaluate if needed and paint dependents dirty""" 381 | if self._value is DIRTY: 382 | self._value = self._evaluate() 383 | LOG.debug('EVALUATED %s: %s', self.name, self._value) 384 | self._set_dependents_dirty() 385 | return self._value 386 | 387 | def set_value(self, new_value): 388 | """Set a new value for an Input 389 | 390 | If this caused the value to change, paints dependent Nodes dirty and 391 | returns the set of those dependent Nodes which are marked "triggered" 392 | and should be re-evaluated. 393 | 394 | """ 395 | return self._set_value(new_value, get_triggered=True) 396 | 397 | value = property(get_value, set_value) 398 | 399 | def _iterate_inputs(self): 400 | """Iterate through positional and keyword inputs""" 401 | return itertools.chain(self._positional_inputs, 402 | values(self._keyword_inputs)) 403 | 404 | def _generate_name(self): 405 | """Generate a unique name for this Node object 406 | 407 | The name includes: 408 | 409 | * the name of the node class 410 | * the function name of the ``_action`` if it's defined 411 | and isn't a lambda 412 | * an auto-incremented number 413 | 414 | """ 415 | action_name = get_func_name(self._action, '') 416 | if action_name == '': 417 | return super(Node, self)._generate_name() 418 | counters = self._name_counters 419 | counters[self.__class__, action_name] += 1 420 | template = '{class_name}-{action_name}-{counter}' 421 | return template.format(class_name=self.__class__.__name__, 422 | action_name=action_name, 423 | counter=counters[self.__class__, action_name]) 424 | 425 | def __lt__(self, other): 426 | return self.name < other.name 427 | 428 | 429 | def update_inputs_iter(inputs_and_values): 430 | """Update values of multiple Inputs and trigger dependents 431 | 432 | This is a generator which iterates through the set of triggered dependent 433 | Nodes. 434 | 435 | """ 436 | triggered = set() 437 | for node, new_value in inputs_and_values: 438 | triggered |= node._set_value(new_value) 439 | for node in triggered: 440 | node.get_value() # trigger evaluation 441 | yield node 442 | 443 | 444 | def update_inputs(inputs_and_values): 445 | """Update values of multiple Inputs and trigger dependents 446 | 447 | Use this variant of the ``update_inputs*`` functions if you don't need to 448 | access the set of triggered dependent Nodes. 449 | 450 | """ 451 | for _node in update_inputs_iter(inputs_and_values): 452 | pass 453 | 454 | 455 | def update_inputs_get_triggered(inputs_and_values): 456 | """Update values of multiple Inputs and trigger dependents 457 | 458 | This variant of the ``update_inputs*`` functions returns triggered 459 | dependent Nodes as a Python set. 460 | 461 | """ 462 | return set(update_inputs_iter(inputs_and_values)) 463 | -------------------------------------------------------------------------------- /lusmu/examples/mouse.py: -------------------------------------------------------------------------------- 1 | """An interactive mouse-based example 2 | 3 | This script tracks whether mouse clicks hit a circle. 4 | 5 | On the screen there's a circle with a fixed center and radius. Mouse clicks 6 | inside and outside the circle to change its color. 7 | 8 | On click (and drag), mouse coordinates are fed into the ``mousex`` and 9 | ``mousey`` input nodes. The ``distance`` node takes those coordinates as 10 | inputs and outputs the distance to the center of the circle. The result is fed 11 | into the ``is_close`` node, which outputs a ``True`` value for distances 12 | smaller than the circle radius. The ``alert`` node returns a string whose 13 | value depends on that boolean value. Finally, the circle changes its color 14 | based on the string in the ``alert`` node. 15 | 16 | You can also observe debug output on the console. Note how the distance 17 | measurement is skipped if the coordinate inputs don't change. 18 | 19 | If you have the graphviz tool installed, you'll also see a diagram of the graph 20 | nodes and connections on the screen. The diagram is saved in ``mouse.gif``. 21 | 22 | """ 23 | 24 | 25 | from lusmu.core import Input, Node, update_inputs 26 | from lusmu.visualization import visualize_graph 27 | import math 28 | import Tkinter 29 | 30 | 31 | TARGET = {'x': 90, 'y': 110} 32 | RADIUS = 30 33 | 34 | 35 | def get_distance(x, y): 36 | print ('Measuring distance from ({x}, {y}) to {t[x]}' 37 | .format(x=x, y=y, t=TARGET)) 38 | dx = x - TARGET['x'] 39 | dy = y - TARGET['y'] 40 | return math.sqrt(dx ** 2 + dy ** 2) 41 | 42 | 43 | def is_close_to_target(distance): 44 | return distance < RADIUS 45 | 46 | 47 | def get_distance_description(is_close): 48 | return "INSIDE" if is_close else "OUTSIDE" 49 | 50 | 51 | mousex = Input(name='mouse x') 52 | mousey = Input(name='mouse y') 53 | distance = Node( 54 | name='distance', 55 | action=get_distance, 56 | inputs=Node.inputs(mousex, mousey)) 57 | is_close = Node( 58 | name='is close', 59 | action=is_close_to_target, 60 | inputs=Node.inputs(distance)) 61 | alert = Node( 62 | name='alert', 63 | action=get_distance_description, 64 | inputs=Node.inputs(is_close)) 65 | 66 | 67 | def onclick(event): 68 | update_inputs([(mousex, event.x), 69 | (mousey, event.y)]) 70 | print 'distance.value == {:.1f}'.format(distance.value) 71 | print 'is_close.value == {!r}'.format(is_close.value) 72 | print 'alert.value == {!r}'.format(alert.value) 73 | print 74 | colors = {'INSIDE': 'red', 'OUTSIDE': 'blue'} 75 | draw_circle(colors[alert.value]) 76 | 77 | 78 | def draw_circle(color): 79 | tx = TARGET['x'] 80 | ty = TARGET['y'] 81 | canvas.create_oval(tx - RADIUS, ty - RADIUS, tx + RADIUS, ty + RADIUS, 82 | fill=color) 83 | 84 | 85 | root = Tkinter.Tk() 86 | frame = Tkinter.Frame(root) 87 | frame.pack(fill=Tkinter.BOTH, expand=1) 88 | canvas = Tkinter.Canvas(frame, background='white') 89 | draw_circle('blue') 90 | canvas.pack(fill=Tkinter.BOTH, expand=1) 91 | canvas.pack() 92 | canvas.bind("", onclick) 93 | canvas.bind("", onclick) 94 | 95 | try: 96 | visualize_graph([alert], 'mouse.gif') 97 | print 'View mouse.gif to see a visualization of the traph.' 98 | diagram = Tkinter.PhotoImage(file='mouse.gif') 99 | canvas.create_image(0, 2 * (TARGET['y'] + RADIUS), 100 | image=diagram, anchor='nw') 101 | except OSError: 102 | print 'Please install graphviz to visualize the graph.' 103 | 104 | root.mainloop() 105 | -------------------------------------------------------------------------------- /lusmu/examples/triangle.py: -------------------------------------------------------------------------------- 1 | from lusmu.core import Input, Node, update_inputs 2 | from lusmu.visualization import visualize_graph 3 | import math 4 | import operator 5 | 6 | 7 | a = Input(name='length of cathetus a') 8 | b = Input(name='length of cathetus b') 9 | 10 | 11 | def square(x): 12 | return x ** 2 13 | 14 | 15 | def sum_(*args): 16 | return sum(args) 17 | 18 | 19 | def sqrt(square): 20 | print '** taking square root of {:.2f}'.format(square) 21 | return math.sqrt(square) 22 | 23 | 24 | area_a = Node(name='square of a', 25 | action=square, 26 | inputs=Node.inputs(a)) 27 | area_b = Node(name='square of b', 28 | action=square, 29 | inputs=Node.inputs(b)) 30 | area_hypothenuse = Node(name='square of hypothenuse', 31 | action=sum_, 32 | inputs=Node.inputs(area_a, area_b)) 33 | hypothenuse = Node(name='length of hypothenuse', 34 | action=sqrt, 35 | inputs=Node.inputs(area_hypothenuse)) 36 | sin_alpha = Node(name='sin of alpha', 37 | action=operator.div, 38 | inputs=Node.inputs(a, hypothenuse)) 39 | alpha = Node(name='angle alpha', 40 | action=math.asin, 41 | inputs=Node.inputs(sin_alpha)) 42 | sin_beta = Node(name='sin of beta', 43 | action=operator.div, 44 | inputs=Node.inputs(b, hypothenuse)) 45 | beta = Node(name='angle beta', 46 | action=math.asin, 47 | inputs=Node.inputs(sin_beta)) 48 | 49 | 50 | print 'Enter float values for a and b, e.g.\n> 3.0 4.0' 51 | while True: 52 | answer = raw_input('\n> ') 53 | if not answer: 54 | break 55 | value_a, value_b = answer.split() 56 | update_inputs([(a, float(value_a)), 57 | (b, float(value_b))]) 58 | print 'Length of hypothenuse: {:.2f}'.format(hypothenuse.value) 59 | print 'Angle alpha: {:.2f} degrees'.format(math.degrees(alpha.value)) 60 | print 'Angle beta: {:.2f} degrees'.format(math.degrees(beta.value)) 61 | 62 | 63 | try: 64 | visualize_graph([hypothenuse], 'triangle.png') 65 | print 'View triangle.png to see a visualization of the traph.' 66 | except OSError: 67 | print 'Please install graphviz to visualize the graph.' 68 | -------------------------------------------------------------------------------- /lusmu/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaihola/lusmu/b20c1a1aa721717862aefeccc19542e0ccea0f0d/lusmu/tests/__init__.py -------------------------------------------------------------------------------- /lusmu/tests/test_core.py: -------------------------------------------------------------------------------- 1 | """Unit tests for lusmu.core 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | 8 | # pylint: disable=W0212 9 | # Access to a protected member of a client class 10 | # pylint: disable=C0103 11 | # Allow long method names 12 | # pylint: disable=R0904 13 | # Allow lots of public methods 14 | 15 | import gc 16 | from unittest import TestCase 17 | from nose.tools import assert_raises 18 | from lusmu.core import (Node, 19 | DIRTY, 20 | Input, 21 | update_inputs_get_triggered, 22 | _TRIGGERED_CACHE) 23 | from mock import patch 24 | import weakref 25 | 26 | 27 | class IncompleteNode(Node): 28 | """A Node subclass which doesn't implement ._evaluate()""" 29 | pass 30 | 31 | 32 | class ConstantNode(Node): 33 | """A Node subclass which always evaluates to the value 1""" 34 | def _evaluate(self): 35 | return 1 36 | 37 | 38 | class NodeTestCase(TestCase): 39 | """Test case for basic functionality of the Node class""" 40 | 41 | def test_missing_evaluate(self): 42 | """The Node class must be subclassed, it doesn't work by itself""" 43 | with self.assertRaises(NotImplementedError): 44 | IncompleteNode('name').get_value() 45 | 46 | def test_name(self): 47 | """A node uses its name in its string representation""" 48 | self.assertEqual('', 49 | repr(ConstantNode('node'))) 50 | 51 | def test_default_name(self): 52 | """A default name is generated for a node if the name is omitted""" 53 | class AutoNamedInput(Input): 54 | """Node subclass for testing automatic names""" 55 | 56 | self.assertEqual('AutoNamedInput-1', AutoNamedInput().name) 57 | 58 | def test_default_name_with_action(self): 59 | """The default name of a node includes action func_name if available""" 60 | class NamedNode(Node): 61 | """Node subclass for testing action names in automatic names""" 62 | 63 | def my_action(_arg): 64 | """Dummy example action""" 65 | 66 | node = NamedNode(action=my_action) 67 | self.assertEqual('NamedNode-my_action-1', node.name) 68 | 69 | def test_node_classes_have_separate_counters(self): 70 | """All Node classes have separate counters for auto-generated names""" 71 | class CounterNodeA(Input): 72 | """Node subclass for testing""" 73 | 74 | self.assertEqual('CounterNodeA-1', CounterNodeA().name) 75 | 76 | class CounterNodeB(Input): 77 | """Node subclass for testing""" 78 | 79 | class CounterNodeC(Node): 80 | """Node subclass for testing""" 81 | 82 | self.assertEqual('CounterNodeB-1', CounterNodeB().name) 83 | self.assertEqual('CounterNodeC-1', CounterNodeC().name) 84 | self.assertEqual('CounterNodeC-2', CounterNodeC().name) 85 | 86 | def test_initial_inputs(self): 87 | """Inputs of a node can be set up in the constructor""" 88 | root = ConstantNode('root') 89 | branch = ConstantNode('branch') 90 | leaf = ConstantNode('node', inputs=([root], {'branch': branch})) 91 | self.assertEqual({leaf}, root._dependents) 92 | self.assertEqual({leaf}, branch._dependents) 93 | self.assertEqual((root,), leaf._positional_inputs) 94 | self.assertEqual({'branch': branch}, leaf._keyword_inputs) 95 | 96 | def test_changing_inputs_disconnects_dependencies(self): 97 | """Old dependencies are disconnected when changing inputs of a Node""" 98 | root1 = ConstantNode('root1') 99 | leaf = ConstantNode('leaf', inputs=([root1], {})) 100 | self.assertEqual({leaf}, root1._dependents) 101 | root2 = ConstantNode('root2') 102 | root3 = ConstantNode('root3') 103 | leaf.set_inputs(root2, foo=root3) 104 | self.assertEqual(set(), root1._dependents) 105 | self.assertEqual({leaf}, root2._dependents) 106 | self.assertEqual({leaf}, root3._dependents) 107 | 108 | def test_initial_value(self): 109 | """The initial value of an Input can be set in the constructor""" 110 | node = Input(value=5) 111 | self.assertEqual(5, node._value) 112 | 113 | def test_value_property_setter(self): 114 | """The value of a Node can be set with the .value property""" 115 | root = Input() 116 | leaf = Node(action=lambda value: value, inputs=Node.inputs(root)) 117 | root.value = 5 118 | self.assertEqual(5, leaf.get_value()) 119 | 120 | def test_value_property_getter(self): 121 | """The value of a Node can be set with the .value property""" 122 | root = Input(value=5) 123 | leaf = Node(action=lambda value: value, inputs=Node.inputs(root)) 124 | self.assertEqual(5, leaf.value) 125 | 126 | 127 | class BaseNodeGarbageCollectionTestCase(TestCase): 128 | """These tests show that nodes are garbage collected 129 | 130 | There is thus no need to use weakrefs when Inputs and Nodes refer to each 131 | other as dependent nodes or input nodes. 132 | 133 | """ 134 | def test_garbage_collection(self): 135 | """Interconnected nodes are garbage collected""" 136 | input_node = Input() 137 | output_node = Node(action=lambda value: value, 138 | inputs=Node.inputs(input_node)) 139 | self.assertEqual(set([output_node]), input_node._dependents) 140 | self.assertEqual((input_node,), output_node._positional_inputs) 141 | input_ref = weakref.ref(input_node) 142 | output_ref = weakref.ref(output_node) 143 | del input_node 144 | del output_node 145 | gc.collect() 146 | self.assertEqual(None, input_ref()) 147 | self.assertEqual(None, output_ref()) 148 | 149 | def test_scope_garbage_collection(self): 150 | """Interconnected nodes which out of scope are garbage collected""" 151 | def inner(): 152 | input_node = Input() 153 | output_node = Node(action=lambda value: value, 154 | inputs=Node.inputs(input_node)) 155 | self.assertEqual(set([output_node]), input_node._dependents) 156 | self.assertEqual((input_node,), output_node._positional_inputs) 157 | return weakref.ref(input_node), weakref.ref(output_node) 158 | 159 | input_ref, output_ref = inner() 160 | gc.collect() 161 | self.assertEqual(None, input_ref()) 162 | self.assertEqual(None, output_ref()) 163 | 164 | def test_garbage_collection_with_finalizer_values(self): 165 | """Interconnected nodes with gc-unfriendly values are gc'd""" 166 | class Val(object): 167 | def __del__(self): 168 | pass 169 | 170 | val = Val() 171 | input_node = Input(value=val) 172 | output_node = Node(action=lambda value: value, 173 | inputs=Node.inputs(input_node)) 174 | self.assertEqual(set([output_node]), input_node._dependents) 175 | self.assertEqual((input_node,), output_node._positional_inputs) 176 | self.assertEqual(val, input_node._value) 177 | input_ref = weakref.ref(input_node) 178 | output_ref = weakref.ref(output_node) 179 | del input_node 180 | del output_node 181 | gc.collect() 182 | self.assertEqual(None, input_ref()) 183 | self.assertEqual(None, output_ref()) 184 | 185 | 186 | class NodeDependentTestCase(TestCase): 187 | """Test case for triggered dependent Nodes""" 188 | 189 | def setUp(self): 190 | self.root = Input('root') 191 | self.dependent = ConstantNode('dependent', triggered=False) 192 | self.root._connect(self.dependent) 193 | self.triggered = ConstantNode('triggered', triggered=True) 194 | self.root._connect(self.triggered) 195 | 196 | def test_keep_dirty(self): 197 | """Setting a dirty Node as dirty doesn't trigger dependents""" 198 | triggered_nodes = self.root.set_value(DIRTY) 199 | self.assertEqual(set(), triggered_nodes) 200 | 201 | def test_set_value_triggers_dependents(self): 202 | """Setting a value to a dirty Node triggers dependents""" 203 | triggered_nodes = self.root.set_value(0) 204 | self.assertEqual({self.triggered}, triggered_nodes) 205 | 206 | def test_set_value_triggers_dependents(self): 207 | """Setting the previous value to an Input doesn't trigger dependents""" 208 | self.root.set_value(0) 209 | triggered_nodes = self.root.set_value(0) 210 | self.assertEqual(set(), triggered_nodes) 211 | 212 | def test_get_triggered_dependents(self): 213 | """Setting a value to a dirty Node triggers dependents""" 214 | triggered_nodes = self.root._get_triggered_dependents() 215 | self.assertEqual({self.triggered}, triggered_nodes) 216 | 217 | def test_get_deep_triggered_dependents(self): 218 | """Setting a value to a dirty Node triggers dependents tree""" 219 | child1 = ConstantNode('child1', triggered=True) 220 | child2 = ConstantNode('child2', triggered=True) 221 | self.triggered._connect(child1) 222 | self.triggered._connect(child2) 223 | triggered_nodes = self.root._get_triggered_dependents() 224 | self.assertEqual({self.triggered, child1, child2}, triggered_nodes) 225 | 226 | 227 | class Counting(object): 228 | """Mixin which counts calls to _get_triggered_dependents()""" 229 | 230 | def __init__(self, *args, **kwargs): 231 | super(Counting, self).__init__(*args, **kwargs) 232 | self.call_count = 0 233 | 234 | def _get_triggered_dependents(self, *args, **kwargs): 235 | self.call_count += 1 236 | return super(Counting, self)._get_triggered_dependents(*args, 237 | **kwargs) 238 | 239 | 240 | class CountingNode(Counting, ConstantNode): 241 | pass 242 | 243 | 244 | class CountingInput(Counting, Input): 245 | pass 246 | 247 | 248 | class TriggeredCacheTestCase(TestCase): 249 | """Test case for the cache of triggered nodes""" 250 | 251 | def setUp(self): 252 | self.root = CountingInput('root') 253 | self.branch = CountingNode('branch', triggered=True) 254 | self.leaf1 = CountingNode('leaf1', triggered=True) 255 | self.leaf2 = CountingNode('leaf2', triggered=True) 256 | self.root._connect(self.branch) 257 | self.branch._connect(self.leaf1) 258 | self.branch._connect(self.leaf2) 259 | 260 | def test_cache_content(self): 261 | """Triggered dependents are cached for each node""" 262 | self.root._get_triggered_dependents() 263 | self.assertEqual({self.root: {self.branch, self.leaf1, self.leaf2}}, 264 | _TRIGGERED_CACHE) 265 | 266 | def test_connect_clears_cache(self): 267 | """Connecting nodes invalidates the triggered nodes cache""" 268 | self.root._get_triggered_dependents() 269 | self.assertEqual({self.root: {self.branch, self.leaf1, self.leaf2}}, 270 | _TRIGGERED_CACHE) 271 | self.root._connect(CountingNode('leaf3')) 272 | self.assertEqual({}, _TRIGGERED_CACHE) 273 | 274 | def test_get_triggered_dependents(self): 275 | """_get_triggered_dependents() isn't called again for cached nodes""" 276 | self.root._get_triggered_dependents() 277 | self.assertEqual(1, self.root.call_count) 278 | self.assertEqual(1, self.branch.call_count) 279 | self.assertEqual(1, self.leaf1.call_count) 280 | self.assertEqual(1, self.leaf2.call_count) 281 | self.root._get_triggered_dependents() 282 | self.assertEqual(2, self.root.call_count) 283 | self.assertEqual(1, self.branch.call_count) 284 | self.assertEqual(1, self.leaf1.call_count) 285 | self.assertEqual(1, self.leaf2.call_count) 286 | 287 | 288 | class NodeSetValueTestCase(TestCase): 289 | """Test case for Node.set_value()""" 290 | 291 | def test_set_value(self): 292 | """A value set to a dirty Node is stored in the object""" 293 | node = Input('name') 294 | node.set_value(0) 295 | self.assertEqual(0, node._value) 296 | 297 | 298 | class UpdateNodesTestCase(TestCase): 299 | """Test case for update_inputs*() methods""" 300 | 301 | def setUp(self): 302 | self.root = CountingInput('root') 303 | self.branch1 = CountingNode('branch1', triggered=True) 304 | self.branch2 = CountingNode('branch2', triggered=True) 305 | self.leaf1 = CountingNode('leaf1', triggered=True) 306 | self.leaf2 = CountingNode('leaf2', triggered=True) 307 | self.leaf3 = CountingNode('leaf3', triggered=True) 308 | self.leaf4 = CountingNode('leaf4', triggered=True) 309 | self.root._connect(self.branch1) 310 | self.root._connect(self.branch2) 311 | self.branch1._connect(self.leaf1) 312 | self.branch1._connect(self.leaf2) 313 | self.branch2._connect(self.leaf3) 314 | self.leaf3._connect(self.leaf4) 315 | 316 | def test_only_leafs_triggered_1(self): 317 | """Updating a Node only triggers its descendents""" 318 | triggered = update_inputs_get_triggered([(self.branch1, 2)]) 319 | self.assertEqual({self.leaf1, self.leaf2}, triggered) 320 | 321 | def test_only_leafs_triggered_2(self): 322 | """Updating a Node only triggers its descendents""" 323 | triggered = update_inputs_get_triggered([(self.branch2, 2)]) 324 | self.assertEqual({self.leaf3, self.leaf4}, triggered) 325 | 326 | 327 | class HomeAutomationTestCase(TestCase): 328 | """Test case illustrating a fictitious home automation use case""" 329 | 330 | def test_home_automation(self): 331 | """A simple example in the home automation domain""" 332 | brightness_1 = Input() 333 | brightness_2 = Input() 334 | brightness_sum = Node(action=lambda *args: sum(args), 335 | inputs=Node.inputs(brightness_1, brightness_2)) 336 | 337 | def inverse(value): 338 | """Return the inverse of a value in the range 0..510""" 339 | return 510 - value 340 | 341 | brightness_inverse = Node(action=inverse, 342 | inputs=Node.inputs(brightness_sum)) 343 | 344 | lamp_power_changes = [] 345 | 346 | def set_lamp_power(value): 347 | """Log changes to lamp power""" 348 | lamp_power_changes.append(value) 349 | 350 | _lamp_power = Node(action=set_lamp_power, 351 | inputs=Node.inputs(brightness_inverse), 352 | triggered=True) 353 | 354 | update_inputs_get_triggered([(brightness_1, 20), 355 | (brightness_2, 40)]) 356 | 357 | self.assertEqual([450], lamp_power_changes) 358 | 359 | update_inputs_get_triggered([(brightness_1, 20), 360 | (brightness_2, 40)]) 361 | 362 | self.assertEqual([450], lamp_power_changes) 363 | 364 | update_inputs_get_triggered([(brightness_1, 24), 365 | (brightness_2, 40)]) 366 | 367 | self.assertEqual([450, 446], lamp_power_changes) 368 | 369 | 370 | class MockActionBase(object): 371 | def __call__(self, data): 372 | return data 373 | 374 | 375 | class NoOutputTypeAction(MockActionBase): 376 | name = 'no' 377 | pass 378 | 379 | 380 | class NoneOutputTypeAction(MockActionBase): 381 | name = 'none' 382 | output_type = None 383 | 384 | 385 | class IntOutputTypeAction(MockActionBase): 386 | name = 'int_action' 387 | output_type = int 388 | 389 | 390 | class NodeVerifyOutputTypeTestCase(TestCase): 391 | def setUp(self): 392 | self.input = Input() 393 | 394 | def test_disabled_and_no_output_type(self): 395 | node = Node(action=NoOutputTypeAction(), 396 | inputs=Node.inputs(self.input)) 397 | self.input.value = '42' 398 | node._evaluate() 399 | 400 | def test_disabled_and_none_output_type(self): 401 | node = Node(action=NoneOutputTypeAction(), 402 | inputs=Node.inputs(self.input)) 403 | self.input.value = '42' 404 | node._evaluate() 405 | 406 | def test_disabled_and_correct_output_type(self): 407 | node = Node(action=IntOutputTypeAction(), 408 | inputs=Node.inputs(self.input)) 409 | self.input.value = 42 410 | node._evaluate() 411 | 412 | def test_disabled_and_wrong_output_type(self): 413 | node = Node(action=IntOutputTypeAction(), 414 | inputs=Node.inputs(self.input)) 415 | self.input.value = '42' 416 | node._evaluate() 417 | 418 | def test_enabled_and_no_output_type(self): 419 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 420 | node = Node(action=NoOutputTypeAction(), 421 | inputs=Node.inputs(self.input)) 422 | self.input.value = '42' 423 | node._evaluate() 424 | 425 | def test_enabled_and_none_output_type(self): 426 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 427 | node = Node(action=NoneOutputTypeAction(), 428 | inputs=Node.inputs(self.input)) 429 | self.input.value = '42' 430 | node._evaluate() 431 | 432 | def test_enabled_and_correct_output_type(self): 433 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 434 | node = Node(action=IntOutputTypeAction(), 435 | inputs=Node.inputs(self.input)) 436 | self.input.value = 42 437 | node._evaluate() 438 | 439 | def test_enabled_and_wrong_output_type(self): 440 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 441 | with assert_raises(TypeError) as exc: 442 | node = Node(name='node', 443 | action=IntOutputTypeAction(), 444 | inputs=Node.inputs(self.input)) 445 | self.input.value = '42' 446 | node._evaluate() 447 | self.assertEqual( 448 | "The output value type 'str' for [node]\n" 449 | "doesn't match the expected type 'int' for action " 450 | '"int_action".', str(exc.exception)) 451 | -------------------------------------------------------------------------------- /lusmu/tests/test_vector.py: -------------------------------------------------------------------------------- 1 | """Test suite for lusmu.vector 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | import tempfile 8 | from unittest import TestCase 9 | 10 | from mock import patch 11 | import joblib 12 | from nose.tools import assert_raises, eq_ 13 | import numpy as np 14 | import pandas as pd 15 | 16 | from lusmu.core import DIRTY 17 | from lusmu.tests.test_core import (NoOutputTypeAction, 18 | NoneOutputTypeAction, 19 | IntOutputTypeAction) 20 | from lusmu.vector import Input 21 | from lusmu import vector 22 | from lusmu.tests.tools import parameterize 23 | 24 | 25 | def sum(*args): 26 | return sum(args) 27 | 28 | 29 | class VectorEq(vector.VectorEquality): 30 | """Mock node class implementing the vector equality test""" 31 | def __init__(self, value): 32 | self._value = value 33 | 34 | 35 | def test_scalar_equality(): 36 | """Test cases for lusmu.vector.VectorEq._value_eq() with Python scalars""" 37 | 38 | @parameterize 39 | def check(value, other_value, expected): 40 | """Scalar node value {0} == {1}: {2}""" 41 | # pylint: disable=W0212 42 | # Access to a protected member of a client class 43 | 44 | vector = VectorEq(value) 45 | assert expected == vector._value_eq(other_value) 46 | 47 | yield check(DIRTY, DIRTY, True) 48 | yield check(DIRTY, 0, False) 49 | yield check(0, 0, True) 50 | yield check(0, 1, False) 51 | yield check(0, 0.0, False) 52 | yield check(0, 1.0, False) 53 | yield check(0.0, 0.0, True) 54 | yield check(0.0, 1.0, False) 55 | yield check('a', 'a', True) 56 | yield check('a', 'b', False) 57 | 58 | 59 | def test_numpy_vector_equality(): 60 | """Test cases for lusmu.vector.VectorEq._value_eq() with numpy arrays""" 61 | 62 | @parameterize 63 | def check(value, other_value, expected): 64 | """Vector node value {0} == {1}: {2}""" 65 | # pylint: disable=W0212 66 | # Access to a protected member of a client class 67 | 68 | vector = VectorEq(np.array(value)) 69 | assert expected == vector._value_eq(np.array(other_value)) 70 | 71 | yield check([], [], True) 72 | yield check([1], [], False) 73 | yield check([], [2], False) 74 | yield check([3], [3], True) 75 | yield check([4], [4, 5], False) 76 | yield check([4], [4, 4], False) 77 | yield check([4, 5], [4], False) 78 | yield check([6, 7, 8], [6, 7, 8], True) 79 | yield check([9, np.nan], [9, np.nan], True) 80 | yield check([9, 10], [9, np.nan], False) 81 | 82 | 83 | def test_numpy_vector_equality_others(): 84 | """Test cases for lusmu.vector.VectorEq._value_eq() with complex data types 85 | """ 86 | 87 | @parameterize 88 | def check(value, other_value, expected): 89 | """Vector node value {0} == {1}: {2}""" 90 | # pylint: disable=W0212 91 | # Access to a protected member of a client class 92 | 93 | vector = VectorEq(value) 94 | assert expected == vector._value_eq(other_value) 95 | 96 | yield check(DIRTY, np.array([[1,2],[3,4]]), False) 97 | yield check(np.array([[1,2],[3,4]]), np.array([[1,2],[3,4]]), True) 98 | yield check(np.array([[1,2],[3,4]]), np.array([[1,2],[3,5]]), False) 99 | yield check(np.array([[1,2],[3,4]]), [[1,2],[3,4]], False) 100 | yield check(np.array([[1,2]]), np.array([[1,2],[1,2]]), False) 101 | 102 | 103 | def test_pandas_vector_equality(): 104 | """Test cases for lusmu.vector.VectorEq._value_eq() with pandas Series""" 105 | 106 | @parameterize 107 | def check(value, index, other_value, other_index, expected): 108 | """Series node value {0}/{1} == {2}/{3}: {4}""" 109 | # pylint: disable=W0212 110 | # Access to a protected member of a client class 111 | this = pd.Series(value, index=pd.to_datetime(index)) 112 | other = pd.Series(other_value, index=pd.to_datetime(other_index)) 113 | vector = VectorEq(this) 114 | 115 | assert expected == vector._value_eq(other) 116 | 117 | yield check([], [], [], [], True) 118 | yield check([1], ['2013-10-15'], [], [], False) 119 | yield check([], [], [2], ['2013-10-15'], False) 120 | yield check([3], ['2013-10-15'], [3], ['2013-10-15'], True) 121 | yield check([4], ['2013-10-15'], [4, 5], 122 | ['2013-10-15', '2013-10-16'], 123 | False) 124 | yield check([4, 5], ['2013-10-15', '2013-10-16'], 125 | [4], ['2013-10-15'], 126 | False) 127 | yield check([6, 7, 8], ['2013-10-15', '2013-10-16', '2013-10-17'], 128 | [6, 7, 8], ['2013-10-15', '2013-10-16', '2013-10-17'], 129 | True) 130 | yield check([6, 7, 8], ['2013-10-15', '2013-10-16', '2013-10-17'], 131 | [6, 7, 8], ['2013-10-15', '2013-10-16', '2013-10-18'], 132 | False) 133 | yield check([9, np.nan], ['2013-10-15', '2013-10-16'], 134 | [9, np.nan], ['2013-10-15', '2013-10-16'], 135 | True) 136 | yield check([9, np.nan], ['2013-10-15', '2013-10-16'], 137 | [9, np.nan], ['2013-10-15', '2013-10-17'], 138 | False) 139 | yield check([9, 10], ['2013-10-15', '2013-10-16'], 140 | [9, np.nan], ['2013-10-15', '2013-10-16'], 141 | False) 142 | 143 | 144 | def test_mixed_vector_equality(): 145 | """Test cases for lusmu.vector.VectorEq._value_eq() with pandas Series""" 146 | 147 | @parameterize 148 | def check(value, index, other_value, expected): 149 | """Series node value {0}/{1} == {2}: {3}""" 150 | # pylint: disable=W0212 151 | # Access to a protected member of a client class 152 | this = pd.Series(value, index=pd.to_datetime(index)) 153 | other = np.array(other_value) 154 | vector = VectorEq(this) 155 | 156 | assert expected == vector._value_eq(other) 157 | 158 | yield check([], [], [], False) 159 | yield check([1], ['2013-10-15'], [], False) 160 | yield check([], [], [2], False) 161 | yield check([3], ['2013-10-15'], [3], False) 162 | yield check([4], ['2013-10-15'], [4, 5], False) 163 | yield check([4, 5], ['2013-10-15', '2013-10-16'], [4], False) 164 | 165 | 166 | class InputSetValueTestCase(TestCase): 167 | def test_no_value(self): 168 | inp = vector.Input() 169 | 170 | eq_(None, inp.last_timestamp) 171 | 172 | def test_dirty_value(self): 173 | inp = vector.Input(value=DIRTY) 174 | 175 | eq_(None, inp.last_timestamp) 176 | 177 | def test_initial_value(self): 178 | inp = vector.Input(value=pd.Series([1, 2], [1001, 1002])) 179 | 180 | eq_(1002, inp.last_timestamp) 181 | 182 | def test_set_value(self): 183 | inp = vector.Input() 184 | inp.value = pd.Series([1, 2], index=[1001, 1002]) 185 | 186 | eq_(1002, inp.last_timestamp) 187 | 188 | def test_scalar_value(self): 189 | inp = vector.Input(value=100000.0) 190 | 191 | eq_(None, inp.last_timestamp) 192 | 193 | def test_array_value(self): 194 | inp = vector.Input(value=np.array([1, 2])) 195 | 196 | eq_(None, inp.last_timestamp) 197 | 198 | 199 | def _pickle_unpickle(data): 200 | with tempfile.NamedTemporaryFile(delete=True) as pickle_file: 201 | pickle_file.close() 202 | 203 | joblib.dump(data, pickle_file.name) 204 | return joblib.load(pickle_file.name) 205 | 206 | 207 | def test_pickling(): 208 | @parameterize 209 | def check(node_class, attribute, value, expected): 210 | """{0.__name__}.{1} pickling works as expected""" 211 | node = node_class() 212 | setattr(node, attribute, value) 213 | 214 | unpickled_node = _pickle_unpickle(node) 215 | 216 | if isinstance(expected, type) and issubclass(expected, Exception): 217 | assert_raises(expected, getattr, unpickled_node, attribute) 218 | else: 219 | value = getattr(unpickled_node, attribute) 220 | if callable(expected): 221 | assert expected(value) 222 | elif isinstance(expected, np.ndarray): 223 | assert vector.vector_eq(expected, value) 224 | else: 225 | assert expected == value 226 | 227 | # arguments: (node class, attribute, value to set, 228 | # expected value/exception/test) 229 | yield check(vector.Input, 'name', 'constant', 230 | 'constant') 231 | yield check(vector.Input, '_value', 42.0, 232 | 42.0) 233 | yield check(vector.Input, '_value', DIRTY, 234 | DIRTY) 235 | yield check(vector.Input, '_value', np.array([42.0]), 236 | np.array([42.0])) 237 | yield check(vector.Input, 'last_timestamp', 1234, 238 | 1234) 239 | yield check(vector.Input, 'extra_attribute', 42.0, 240 | AttributeError) 241 | 242 | yield check(vector.Node, 'name', 'constant', 243 | 'constant') 244 | yield check(vector.Node, '_value', 42.0, 245 | 42.0) 246 | yield check(vector.Node, '_value', np.array([42.0]), 247 | np.array([42.0])) 248 | yield check(vector.Node, '_action', sum, 249 | lambda _action: _action == sum) 250 | yield check(vector.Node, 'triggered', True, 251 | True) 252 | yield check(vector.Node, '_positional_inputs', 253 | (vector.Input(name='foo'),), 254 | (lambda _positional_inputs: 255 | [n.name for n in _positional_inputs] == ['foo'])) 256 | yield check(vector.Node, '_keyword_inputs', 257 | {'foo': vector.Input(name='bar')}, 258 | (lambda _keyword_inputs: 259 | {k: v.name for k, v in _keyword_inputs.items()} 260 | == {'foo': 'bar'})) 261 | yield check(vector.Node, 'extra_attribute', 42.0, 262 | AttributeError) 263 | 264 | 265 | def test_input_equality(): 266 | @parameterize 267 | def check(_, a, b, expected): 268 | """Input.__eq__ is {3} for {0}""" 269 | result = a == b 270 | eq_(expected, result) 271 | 272 | yield check('unnamed (auto-named) dirty value inputs', 273 | Input(name=None, value=DIRTY), Input(name=None, value=DIRTY), 274 | False) 275 | yield check('non-matching names', 276 | Input(name='a', value=DIRTY), Input(name='b', value=DIRTY), 277 | False) 278 | yield check('named vs. unnamed node', 279 | Input(name='a', value=DIRTY), Input(name=None, value=DIRTY), 280 | False) 281 | 282 | 283 | class VectorNodeVerifyOutputTypeTestCase(TestCase): 284 | def setUp(self): 285 | self.input = Input() 286 | 287 | def test_disabled_and_no_output_type(self): 288 | node = vector.Node(action=NoOutputTypeAction(), 289 | inputs=vector.Node.inputs(self.input)) 290 | self.input.value = np.array(['42']) 291 | node._evaluate() 292 | 293 | def test_disabled_and_none_output_type(self): 294 | node = vector.Node(action=NoneOutputTypeAction(), 295 | inputs=vector.Node.inputs(self.input)) 296 | self.input.value = np.array(['42']) 297 | node._evaluate() 298 | 299 | def test_disabled_and_correct_output_type(self): 300 | node = vector.Node(action=IntOutputTypeAction(), 301 | inputs=vector.Node.inputs(self.input)) 302 | self.input.value = np.array([42]) 303 | node._evaluate() 304 | 305 | def test_disabled_and_wrong_output_type(self): 306 | node = vector.Node(action=IntOutputTypeAction(), 307 | inputs=vector.Node.inputs(self.input)) 308 | self.input.value = np.array(['42']) 309 | node._evaluate() 310 | 311 | def test_enabled_and_no_output_type(self): 312 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 313 | node = vector.Node(action=NoOutputTypeAction(), 314 | inputs=vector.Node.inputs(self.input)) 315 | self.input.value = np.array(['42']) 316 | node._evaluate() 317 | 318 | def test_enabled_and_none_output_type(self): 319 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 320 | node = vector.Node(action=NoneOutputTypeAction(), 321 | inputs=vector.Node.inputs(self.input)) 322 | self.input.value = np.array(['42']) 323 | node._evaluate() 324 | 325 | def test_enabled_and_correct_output_type(self): 326 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 327 | node = vector.Node(action=IntOutputTypeAction(), 328 | inputs=vector.Node.inputs(self.input)) 329 | self.input.value = np.array([42]) 330 | node._evaluate() 331 | 332 | def test_enabled_and_wrong_output_type(self): 333 | with patch('lusmu.core.VERIFY_OUTPUT_TYPES', True): 334 | with assert_raises(TypeError) as exc: 335 | node = vector.Node(name='node', 336 | action=IntOutputTypeAction(), 337 | inputs=vector.Node.inputs(self.input)) 338 | self.input.value = np.array(['42']) 339 | node._evaluate() 340 | self.assertEqual( 341 | "The output value type 'string_' for [node]\n" 342 | "doesn't match the expected type 'int' for action " 343 | '"int_action".', str(exc.exception)) 344 | -------------------------------------------------------------------------------- /lusmu/tests/tools.py: -------------------------------------------------------------------------------- 1 | """Tools for the Lusmu test suite 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | 8 | 9 | def parameterize(func): 10 | """Decorator for setting test function description based on arguments 11 | 12 | Example:: 13 | 14 | def test_plus(): 15 | 16 | @parameterize 17 | def check(a, b, expected): 18 | '''plus({0}, {1}) equals {2}''' 19 | assert plus(a, b) == expected 20 | 21 | yield check(1, 1, 2) 22 | yield check(2, 2, 4) 23 | 24 | """ 25 | def get_test_call_info(*args): 26 | """Set test function description and return yieldable tuple""" 27 | if func.__doc__: 28 | func.description = func.__doc__.format(*args) 29 | else: 30 | func.description = func.__doc__ 31 | return (func,) + args 32 | 33 | return get_test_call_info 34 | -------------------------------------------------------------------------------- /lusmu/vector.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | """The lazy evaluation graph adapted for numpy arrays and pandas Series 4 | 5 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 6 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 7 | 8 | """ 9 | 10 | # pylint: disable=W0611 11 | # update_inputs is provided as a convenience for importing it from the 12 | # same place as the Input and Node classes 13 | # pylint: disable=R0903 14 | # mixins have few public methods, that's ok 15 | 16 | import logging 17 | from lusmu.core import (DIRTY, 18 | Input as LusmuInput, 19 | Node as LusmuNode, 20 | update_inputs) 21 | import numexpr as ne 22 | import numpy as np 23 | import pandas as pd 24 | 25 | 26 | def vector_eq(a, b): 27 | """Return True if vectors are equal, comparing NaNs correctly too 28 | 29 | Arguments 30 | --------- 31 | a, b: numpy.array 32 | The vectors to compare 33 | 34 | """ 35 | # pylint: disable=C0103 36 | # allow one-letter function arguments 37 | 38 | a_length, b_length = len(a), len(b) 39 | if a_length != b_length: 40 | # comparing np.array([]) to np.array([1]) only works this way 41 | return False 42 | if not a_length and not b_length: 43 | # dtypes might be wrong for empty arrays 44 | return True 45 | # Consider NaNs equal; see http://stackoverflow.com/a/10821267 46 | return np.all(ne.evaluate('(a==b)|((a!=a)&(b!=b))')) 47 | 48 | 49 | class VectorEquality(object): 50 | """Mixin for extending Lusmu Inputs and Nodes to work with vector values""" 51 | def _value_eq(self, other_value): 52 | """Replace the equality test of Input/Node values 53 | 54 | Lusmu uses the ``==`` operator by default. It doesn't work correctly 55 | with vectors which have more than one value – ``bool(vec1 == vec2)`` 56 | raises an exception. 57 | 58 | """ 59 | # pylint: disable=E1101 60 | # (Instance of VectorEquality has no _value member) 61 | # This class will be mixed into ones that have _value 62 | a = self._value 63 | b = other_value 64 | try: 65 | if type(a) != type(b): 66 | return False 67 | if len(a)==0 and len(b)==0: 68 | return True 69 | if a.shape != b.shape: 70 | return False 71 | if not np.all(ne.evaluate('(a==b)|((a!=a)&(b!=b))')): 72 | return False 73 | if hasattr(a, 'index') and hasattr(b, 'index'): 74 | # The values are Pandas Series with time indices. Compare time 75 | # indices, too. 76 | a_ind = VectorEquality() 77 | # FIXME: We should support non-number indices as well! 78 | a_ind._value = a.index.values.astype(float) 79 | return a_ind._value_eq(b.index.values.astype(float)) 80 | return True 81 | except (AttributeError, TypeError): 82 | # not pandas or numpy objects 83 | return ( 84 | type(self._value) == type(other_value) and 85 | self._value == other_value) 86 | 87 | 88 | class NodePickleMixin(object): 89 | """Mixin defining the attributes to pickle for all node types""" 90 | _state_attributes = 'name', '_dependents', '_value' 91 | 92 | def __getstate__(self): 93 | return {key: getattr(self, key) 94 | for key in self._state_attributes} 95 | 96 | 97 | class Input(NodePickleMixin, VectorEquality, LusmuInput): 98 | """Vector compatible Lusmu Input 99 | 100 | The value of the input node is always set dirty when unpickling. 101 | 102 | """ 103 | _state_attributes = NodePickleMixin._state_attributes + ('last_timestamp',) 104 | 105 | def __init__(self, name=None, value=DIRTY): 106 | super(Input, self).__init__(name=name, value=value) 107 | self.last_timestamp = self._get_max_timestamp(value) 108 | 109 | @staticmethod 110 | def _get_max_timestamp(value): 111 | """Return the latest timestamp in the Series 112 | 113 | Arguments 114 | --------- 115 | value: pandas.Series with a timestamp index 116 | 117 | """ 118 | if isinstance(value, pd.Series) and len(value): 119 | return value.index[-1] 120 | 121 | def _set_value(self, value, get_triggered=True): 122 | """Keep track of latest timestamp processed""" 123 | new_last_timestamp = self._get_max_timestamp(value) 124 | if new_last_timestamp: 125 | self.last_timestamp = new_last_timestamp 126 | return super(Input, self)._set_value(value, get_triggered=get_triggered) 127 | 128 | def __eq__(self, other): 129 | """Equality comparison provided for unit test convenience""" 130 | return self.name == other.name and self._value_eq(other.value) 131 | 132 | 133 | class Node(NodePickleMixin, VectorEquality, LusmuNode): 134 | """Vector compatible Lusmu Node""" 135 | _state_attributes = (NodePickleMixin._state_attributes + 136 | ('_action', 137 | 'triggered', 138 | '_positional_inputs', 139 | '_keyword_inputs')) 140 | 141 | def _verify_output_type(self, value): 142 | """Assert that the given value matches the action's output type 143 | 144 | This adds NumPy/Pandas dtype support to output type verification. 145 | 146 | """ 147 | if hasattr(value, 'dtype'): 148 | if not issubclass(value.dtype.type, self._action.output_type): 149 | raise TypeError( 150 | "The output value type {value.dtype.type.__name__!r} " 151 | "for [{self.name}]\n" 152 | "doesn't match the expected type " 153 | "{self._action.output_type.__name__!r} for action " 154 | '"{self._action.name}".' 155 | .format(value=value, self=self)) 156 | else: 157 | super(Node, self)._verify_output_type(value) 158 | 159 | def _evaluate(self): 160 | """Log a message when evaluating a node""" 161 | # pylint: disable=E1101 162 | # self.name comes from lusmu 163 | logger = logging.getLogger(__name__) 164 | logger.debug('[%s]._evaluate()', self.name) 165 | return super(Node, self)._evaluate() 166 | 167 | def __eq__(self, other): 168 | """Equality comparison provided for unit test convenience""" 169 | return self.__dict__ == other.__dict__ 170 | -------------------------------------------------------------------------------- /lusmu/visualization.py: -------------------------------------------------------------------------------- 1 | """Tools for visualizing a lusmu graph 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | 8 | # pylint: disable=W0212 9 | # Allow access to protected members of client classes 10 | # pylint: disable=W0142 11 | # Allow * and ** magic 12 | 13 | from __future__ import print_function, unicode_literals 14 | import re 15 | from textwrap import dedent 16 | 17 | from lusmu.core import Input, Node 18 | import subprocess 19 | 20 | 21 | def collect_nodes(collected_nodes, *args): 22 | """Collect all nodes belonging to the same graph 23 | 24 | Walks dependent Nodes and inputs recursively. 25 | 26 | """ 27 | if not args: 28 | return 29 | node = args[0] 30 | rest = args[1:] 31 | collect_nodes(collected_nodes, *rest) 32 | if node in collected_nodes: 33 | return 34 | collected_nodes.add(node) 35 | collect_nodes(collected_nodes, *node._dependents) 36 | if isinstance(node, Node): 37 | collect_nodes(collected_nodes, *node._iterate_inputs()) 38 | 39 | 40 | def get_action_name(action): 41 | """Try to return a good representation of the name of an action callable""" 42 | if hasattr(action, 'name'): 43 | return action.name 44 | if hasattr(action, '__name__'): 45 | return action.__name__ 46 | if hasattr(action, 'func_name'): 47 | return action.func_name 48 | return action.__class__.__name__ 49 | 50 | 51 | def format_node_default(node_id, node): 52 | shape = 'oval' if isinstance(node, Input) else 'box' 53 | action = ('{br}{br}{action}' 54 | .format(br='
', 55 | action=get_action_name(node._action)) 56 | if isinstance(node, Node) 57 | else '') 58 | yield (' {node_id} ' 59 | '[label=<{name}' 60 | '{action}>' 61 | ' shape={shape}];' 62 | .format(node_id=node_id, 63 | name=node.name.replace(':', ':
'), 64 | action=action, 65 | shape=shape)) 66 | yield ' edge [color=blue];' 67 | 68 | 69 | def graphviz_lines(nodes, node_filter, format_node): 70 | """Generate source lines for a Graphviz graph definition""" 71 | all_nodes = set() 72 | collect_nodes(all_nodes, *nodes) 73 | if node_filter: 74 | all_nodes = [n for n in all_nodes if node_filter(n)] 75 | all_nodes = sorted(all_nodes, key=id) 76 | input_nodes = [n for n in all_nodes if isinstance(n, Input)] 77 | 78 | yield 'digraph gr {' 79 | yield ' graph [ dpi = 48 ];' 80 | yield ' rankdir = LR;' 81 | yield ' { rank = source;' 82 | for node in input_nodes: 83 | yield ' n{};'.format(id(node)) 84 | yield ' }' 85 | for node in all_nodes: 86 | for line in format_node('n{}'.format(id(node)), node): 87 | yield line 88 | for other in node._dependents: 89 | if other in all_nodes: 90 | yield (' n{node} -> n{other};' 91 | .format(node=id(node), other=id(other))) 92 | yield '}' 93 | 94 | 95 | def visualize_graph(nodes, filename, 96 | node_filter=lambda node: True, 97 | format_node=format_node_default): 98 | """Saves a visualization of given nodes in an image file""" 99 | image_format = filename.split('.')[-1].lower() 100 | graphviz = subprocess.Popen(['dot', 101 | '-T{}'.format(image_format), 102 | '-o', filename], 103 | stdin=subprocess.PIPE) 104 | source = '\n'.join(graphviz_lines(nodes, 105 | node_filter, 106 | format_node)) 107 | graphviz.communicate(source.encode('utf-8')) 108 | 109 | # Add some CSS to SVG images 110 | if image_format == 'svg': 111 | with open(filename) as svg_file: 112 | svg = svg_file.read() 113 | svg = re.sub(r'(]*>)', 114 | dedent(r''' 115 | \1 116 | 121 | '''), 122 | svg, 123 | re.S) 124 | with open(filename, 'w') as svg_file: 125 | svg_file.write(svg) 126 | 127 | return source 128 | -------------------------------------------------------------------------------- /requirements/documentation.txt: -------------------------------------------------------------------------------- 1 | Jinja2==3.1.4 2 | MarkupSafe==0.18 3 | Pygments==2.15.0 4 | Sphinx==1.2b1 5 | cloud-sptheme==1.5 6 | docutils==0.11 7 | -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | astroid==1.0.1 2 | coverage==3.7 3 | joblib==1.2.0 4 | logilab-common==0.61.0 5 | mock==1.0.1 6 | nose==1.3.0 7 | nosexcover==1.0.8 8 | numexpr==2.8.5 9 | pep8==1.4.6 10 | pylint==1.0.0 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Lusmu setup information 2 | 3 | Copyright 2013 Eniram Ltd. See the LICENSE file at the top-level directory of 4 | this distribution and at https://github.com/akaihola/lusmu/blob/master/LICENSE 5 | 6 | """ 7 | 8 | import os 9 | from setuptools import setup 10 | 11 | 12 | here = os.path.abspath(os.path.dirname(__file__)) 13 | with open(os.path.join(here, 'README.rst')) as f: 14 | README = f.read() 15 | 16 | 17 | setup(name='lusmu', 18 | version='0.2.4.dev', 19 | packages=['lusmu'], 20 | author='Antti Kaihola', 21 | author_email='antti.kaihola@eniram.fi', 22 | license='BSD', 23 | description='A dataflow/reactive programming library for Python', 24 | long_description=README, 25 | keywords='eniram dataflow reactive', 26 | url='https://github.com/akaihola/lusmu', 27 | test_suite='nose.collector', 28 | tests_require=['mock==1.0.1', 'nose==1.3.0']) 29 | --------------------------------------------------------------------------------