├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── doc ├── Makefile ├── basics.rst ├── conf.py ├── examples.rst ├── index.rst ├── introduction.rst ├── make.bat ├── turnstile-complex.py ├── turnstile.py └── turnstile.rst ├── machinist ├── __init__.py ├── _fsm.py ├── _interface.py ├── _logging.py ├── _version.py ├── test │ ├── __init__.py │ ├── loglib.py │ └── test_fsm.py └── topfiles │ ├── 10.feature │ ├── 14.bugfix │ ├── 20.misc │ ├── 5.bugfix │ ├── 6.feature │ ├── 7.feature │ └── 9.doc ├── setup.py └── versioneer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | machinist/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | _trial_temp 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | - pypy 6 | 7 | env: 8 | # Test against the minimum supported version of eliot 9 | - | 10 | ELIOT='echo -n "eliot=="; python -c "from setup import _MINIMUM_ELIOT_VERSION; print _MINIMUM_ELIOT_VERSION"' 11 | 12 | # Test against the released version of eliot which pip will choose to 13 | # install. 14 | - | 15 | ELIOT='echo eliot' 16 | 17 | # Test against master@HEAD version of eliot. 18 | - | 19 | ELIOT='echo git+https://github.com/ClusterHQ/eliot.git' 20 | 21 | # Test with a too-old version of eliot. 22 | - | 23 | ELIOT='echo eliot==0.3.0' 24 | 25 | # Test without any version of eliot. 26 | - | 27 | NO_ELIOT='' 28 | 29 | # Build the documentation. 30 | - | 31 | SPHINX_TARGET=html 32 | 33 | # Run the tests for the documentation. 34 | - | 35 | SPHINX_TARGET=doctest 36 | 37 | matrix: 38 | exclude: 39 | - python: "pypy" 40 | env: "SPHINX_TARGET=html" 41 | - python: "pypy" 42 | env: "SPHINX_TARGET=doctest" 43 | 44 | install: 45 | - | 46 | pip install coveralls coverage 47 | 48 | - | 49 | python setup.py --version 50 | 51 | # Install the selected eliot version. 52 | - | 53 | if [ -v ELIOT ]; then 54 | pip install "$(eval ${ELIOT})" 55 | fi 56 | 57 | - | 58 | pip install --editable .[dev] 59 | 60 | script: 61 | - | 62 | # Do a Sphinx thing if appropriate. 63 | if [ -v SPHINX_TARGET ]; then 64 | make --directory=doc "${SPHINX_TARGET}" 65 | else 66 | coverage run --branch --source machinist $(type -p trial) machinist 67 | fi 68 | - | 69 | # Don't bother generating a coverage report if we just did Sphinx stuff. 70 | if [ ! -v SPHINX_TARGET ]; then 71 | coverage report --show-missing 72 | fi 73 | 74 | after_success: 75 | - | 76 | # Don't bother reporting to coveralls if we just did Sphinx stuff. 77 | if [ ! -v SPHINX_TARGET ]; then 78 | coveralls 79 | fi 80 | 81 | notifications: 82 | email: false 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versioneer.py 2 | include machinist/_version.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/ClusterHQ/machinist.png 2 | :target: https://travis-ci.org/ClusterHQ/machinist 3 | 4 | .. image:: https://coveralls.io/repos/hybridcluster/machinist/badge.png 5 | :target: https://coveralls.io/r/hybridcluster/machinist 6 | 7 | 8 | Installation 9 | ~~~~~~~~~~~~ 10 | 11 | .. code-block:: console 12 | 13 | $ pip install machinist 14 | 15 | Machinist's automatic structured logging depends on `eliot `_. 16 | Logging is declared as a Machinist extra so you can automatically install this dependency: 17 | 18 | .. code-block:: console 19 | 20 | $ pip install machinist[logging] 21 | 22 | 23 | Defining Inputs, Outputs, and States 24 | ------------------------------------ 25 | 26 | Inputs, outputs, and states are all ``twisted.python.constants.NamedConstant``. 27 | Collections of inputs, outputs, and states are ``twisted.python.constants.Names``. 28 | 29 | .. code-block:: python 30 | 31 | class TurnstileInput(Names): 32 | FARE_PAID = NamedConstant() 33 | ARM_UNLOCKED = NamedConstant() 34 | ARM_TURNED = NamedConstant() 35 | ARM_LOCKED = NamedConstant() 36 | 37 | class TurnstileOutput(Names): 38 | ENGAGE_LOCK = NamedConstant() 39 | DISENGAGE_LOCK = NamedConstant() 40 | 41 | class TurnstileState(Names): 42 | LOCKED = NamedConstant() 43 | UNLOCKED = NamedConstant() 44 | ACTIVE = NamedConstant() 45 | 46 | 47 | Defining the Transitions 48 | ------------------------ 49 | 50 | A transition is defined as an input to a state mapped to a series of outputs and the next state. 51 | 52 | These transitions are added to a transition table. 53 | 54 | .. code-block:: python 55 | 56 | table = TransitionTable() 57 | 58 | # Any number of things like this 59 | table = table.addTransitions( 60 | TurnstileState.UNLOCKED, { 61 | TurnstileInput.ARM_TURNED: 62 | ([TurnstileOutput.ENGAGE_LOCK], TurnstileState.ACTIVE), 63 | }) 64 | 65 | If an input is received for a particular state for which it is not defined, an ``machinist.IllegalInput`` would be raised. 66 | In the example above, if ``FARE_PAID`` is received as an input while the turnstile is in the ``UNLOCKED`` state, ``machinist.IllegalInput`` will be raised. 67 | 68 | 69 | Putting together the Finite State Machine 70 | ----------------------------------------- 71 | 72 | To build an instance of a finite state machine from the transition, pass the inputs, outputs, states, and table (previously defined) to the function ``machinist.constructFiniteStateMachine``. 73 | 74 | .. code-block:: python 75 | 76 | turnstileFSM = constructFiniteStateMachine( 77 | inputs=TurnstileInput, 78 | outputs=TurnstileOutput, 79 | states=TurnstileState, 80 | table=table, 81 | initial=TurnstileState.LOCKED, 82 | richInputs=[] 83 | inputContext={}, 84 | world=MethodSuffixOutputer(Turnstile(hardware)), 85 | ) 86 | 87 | Note that ``richInputs`` must be passed and it must be a list of ``IRichInput`` providers mapped to the same input symbols (parameter ``inputs``) the FSM is created with. 88 | 89 | ``Turnstile`` is a class with methods named ``output_XXX``, where ``XXX`` is one of the outputs. 90 | There should be one such method for each output defined. 91 | 92 | 93 | Transitioning the Finite State Machine 94 | -------------------------------------- 95 | 96 | To provide an input to the FSM, ``receive`` on the FSM must be called with an instance of an ``IRichInput`` provider. 97 | 98 | .. code-block:: python 99 | 100 | turnstileFSM.receive(TurnstileInput.FARE_PAID) 101 | 102 | 103 | Further Reading 104 | --------------- 105 | 106 | For the rest of the example code, see `doc/turnstile.py `_. 107 | 108 | For more discussion of the benefits of using finite state machines, see: 109 | 110 | * https://www.clusterhq.com/blog/what-is-a-state-machine/ 111 | * https://www.clusterhq.com/blog/benefits-state-machine/ 112 | * https://www.clusterhq.com/blog/unit-testing-state-machines/ 113 | * https://www.clusterhq.com/blog/isolating-side-effects-state-machines/ 114 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build -W 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 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 " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/machinist.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/machinist.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/machinist" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/machinist" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /doc/basics.rst: -------------------------------------------------------------------------------- 1 | .. _Basic Usage: 2 | 3 | Basic Usage 4 | =========== 5 | 6 | State machines are constructed using :py:func:`machinist.constructFiniteStateMachine`. 7 | 8 | Inputs, Outputs, States 9 | ----------------------- 10 | 11 | Before a machine can be constructed its inputs, outputs, and states must be defined. 12 | These are all defined using :py:mod:`twisted.python.constants`. 13 | 14 | .. literalinclude:: turnstile.py 15 | :start-after: begin setup 16 | :end-before: end setup 17 | 18 | Transitions 19 | ----------- 20 | 21 | Also required is a transition table. 22 | The transition table is defined using :py:class:`machinist.TransitionTable`. 23 | :py:class:`TransitionTable` instances are immutable and have several methods for creating new tables including more transitions. 24 | 25 | .. literalinclude:: turnstile.py 26 | :start-after: begin table def 27 | :end-before: end table def 28 | 29 | First, define how the ``FARE_PAID`` input is handled in the ``LOCKED`` state: output ``DISENGAGE_LOCK`` and change the state to the ``ACTIVE``. 30 | 31 | .. literalinclude:: turnstile.py 32 | :start-after: begin first transition 33 | :end-before: end first transition 34 | 35 | Next, define how the ``ARM_TURNED`` input is handled in the ``UNLOCKED`` state: output ``ENGAGE_LOCK`` and change the state to ``ACTIVE``. 36 | 37 | .. literalinclude:: turnstile.py 38 | :start-after: begin second transition 39 | :end-before: end second transition 40 | 41 | Last, define two transitions at once for getting out of the ``ACTIVE`` state 42 | (in this model ``DISENGAGE_LOCK`` and ``ENGAGE_LOCK`` activate a physical device to change the lock state; 43 | the state machine then waits for an input indicating the physical device has completed the desired operation). 44 | 45 | ``addTransitions`` is a convenient way to define more than one transition at once. 46 | It is equivalent to several ``addTransition`` calls. 47 | 48 | .. literalinclude:: turnstile.py 49 | :start-after: begin last transitions 50 | :end-before: end last transitions 51 | 52 | One thing to note here is that the outputs are ``list``\ s of symbols from the output set. 53 | The output of any transition in Machinist is always a ``list``. 54 | This simplifies the definition of output symbols in many cases and grants more flexibility in how a machine can react to an input. 55 | You can see one way in which this is useful already: the transitions out of the ``ACTIVE`` state have no useful outputs and so use an empty ``list``. 56 | The handling of these ``list``\ s of outputs is discussed in more detail in the next section, `Output Executors`_. 57 | 58 | 59 | Output Executors 60 | ---------------- 61 | 62 | The last thing that must be defined in order to create any state machine using Machinist is an *output executor*. 63 | In the previous sections we saw how the outputs of a state machine must be defined and how transitions must specify the outputs of each transition. 64 | The outputs that have been defined so far are only symbols: they can't have any impact on the world. 65 | This makes them somewhat useless until they are combined with code that knows how to turn an output symbol into an **actual** output. 66 | This is the output executor's job. 67 | Machinist provides a helper for writing classes that turn output symbols into side-effects: 68 | 69 | .. literalinclude:: turnstile.py 70 | :start-after: begin outputer 71 | :end-before: end outputer 72 | 73 | When used as the output executor for a state machine, the methods of this instance will be called according to the names of the outputs that are produced. 74 | That is, when a transition is executed which has :py:obj:`Output.ENGAGE_LOCK` as an output, :py:meth:`output_ENGAGE_LOCK` will be called. 75 | This lets the application define arbitrary side-effects to associate with outputs. 76 | In this well-defined way the otherwise rigid, structured, explicit state machine can interact with the messy world. 77 | 78 | 79 | Construction 80 | ------------ 81 | 82 | Having defined these things, we can now use :py:func:`constructFiniteStateMachine` to construct the finite state machine. 83 | 84 | .. literalinclude:: turnstile.py 85 | :start-after: begin construct 86 | :end-before: end construct 87 | 88 | Apart from the inputs, outputs, states, transition table, and output executor, the only other argument to pay attention to in this call right now is *initial*. 89 | This defines the state that the state machine is in immediately after :py:func:`constructFiniteStateMachine` returns. 90 | 91 | 92 | Receiving Inputs 93 | ---------------- 94 | 95 | Having created a state machine, we can now deliver inputs to it. 96 | The simplest way to do this is to pass input symbols to the :py:attr:`receive` method: 97 | 98 | .. literalinclude:: turnstile.py 99 | :start-after: begin inputs 100 | :end-before: end inputs 101 | 102 | If we :ref:`combine all of these snippets ` and call :py:func:`cycle` the result is a program that produces this result: 103 | 104 | .. testsetup:: turnstile 105 | 106 | import turnstile 107 | 108 | .. testcode:: turnstile 109 | :hide: 110 | 111 | turnstile.cycle() 112 | 113 | .. testoutput:: turnstile 114 | 115 | Disengaging the lock. 116 | Engaging the lock. 117 | 118 | .. testcode:: turnstile-main 119 | :hide: 120 | 121 | execfile("turnstile.py", {"__name__": "__main__"}) 122 | 123 | .. testoutput:: turnstile-main 124 | :hide: 125 | 126 | Disengaging the lock. 127 | Engaging the lock. 128 | 129 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # machinist documentation build configuration file, created by 4 | # sphinx-quickstart on Fri May 23 13:53:35 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os.path 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = [ 30 | 'sphinx.ext.intersphinx', 31 | 'sphinx.ext.doctest', 32 | ] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = '.rst' 39 | 40 | # The encoding of source files. 41 | #source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = 'index' 45 | 46 | # General information about the project. 47 | project = u'machinist' 48 | copyright = u'2014, Jean-Paul Calderone' 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | version = '0.1.0' 56 | # The full version, including alpha/beta/rc tags. 57 | release = '0.1.0' 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | #language = None 62 | 63 | # There are two options for replacing |today|: either, you set today to some 64 | # non-false value, then it is used: 65 | #today = '' 66 | # Else, today_fmt is used as the format for a strftime call. 67 | #today_fmt = '%B %d, %Y' 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | exclude_patterns = ['_build'] 72 | 73 | # The reST default role (used for this markup: `text`) to use for all documents. 74 | #default_role = None 75 | 76 | # If true, '()' will be appended to :func: etc. cross-reference text. 77 | add_function_parentheses = False 78 | 79 | # If true, the current module name will be prepended to all description 80 | # unit titles (such as .. function::). 81 | #add_module_names = True 82 | 83 | # If true, sectionauthor and moduleauthor directives will be shown in the 84 | # output. They are ignored by default. 85 | #show_authors = False 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = 'sphinx' 89 | 90 | # A list of ignored prefixes for module index sorting. 91 | #modindex_common_prefix = [] 92 | 93 | 94 | # -- Options for HTML output --------------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. See the documentation for 97 | # a list of builtin themes. 98 | html_theme = 'default' 99 | 100 | # Theme options are theme-specific and customize the look and feel of a theme 101 | # further. For a list of options available for each theme, see the 102 | # documentation. 103 | #html_theme_options = {} 104 | 105 | # Add any paths that contain custom themes here, relative to this directory. 106 | #html_theme_path = [] 107 | 108 | # The name for this set of Sphinx documents. If None, it defaults to 109 | # " v documentation". 110 | #html_title = None 111 | 112 | # A shorter title for the navigation bar. Default is the same as html_title. 113 | #html_short_title = None 114 | 115 | # The name of an image file (relative to this directory) to place at the top 116 | # of the sidebar. 117 | #html_logo = None 118 | 119 | # The name of an image file (within the static path) to use as favicon of the 120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 121 | # pixels large. 122 | #html_favicon = None 123 | 124 | # Add any paths that contain custom static files (such as style sheets) here, 125 | # relative to this directory. They are copied after the builtin static files, 126 | # so a file named "default.css" will overwrite the builtin "default.css". 127 | html_static_path = [] 128 | 129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 130 | # using the given strftime format. 131 | #html_last_updated_fmt = '%b %d, %Y' 132 | 133 | # If true, SmartyPants will be used to convert quotes and dashes to 134 | # typographically correct entities. 135 | #html_use_smartypants = True 136 | 137 | # Custom sidebar templates, maps document names to template names. 138 | #html_sidebars = {} 139 | 140 | # Additional templates that should be rendered to pages, maps page names to 141 | # template names. 142 | #html_additional_pages = {} 143 | 144 | # If false, no module index is generated. 145 | #html_domain_indices = True 146 | 147 | # If false, no index is generated. 148 | #html_use_index = True 149 | 150 | # If true, the index is split into individual pages for each letter. 151 | #html_split_index = False 152 | 153 | # If true, links to the reST sources are added to the pages. 154 | #html_show_sourcelink = True 155 | 156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 157 | #html_show_sphinx = True 158 | 159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 160 | #html_show_copyright = True 161 | 162 | # If true, an OpenSearch description file will be output, and all pages will 163 | # contain a tag referring to it. The value of this option must be the 164 | # base URL from which the finished HTML is served. 165 | #html_use_opensearch = '' 166 | 167 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 168 | #html_file_suffix = None 169 | 170 | # Output file base name for HTML help builder. 171 | htmlhelp_basename = 'machinistdoc' 172 | 173 | 174 | # -- Options for LaTeX output -------------------------------------------------- 175 | 176 | latex_elements = { 177 | # The paper size ('letterpaper' or 'a4paper'). 178 | #'papersize': 'letterpaper', 179 | 180 | # The font size ('10pt', '11pt' or '12pt'). 181 | #'pointsize': '10pt', 182 | 183 | # Additional stuff for the LaTeX preamble. 184 | #'preamble': '', 185 | } 186 | 187 | # Grouping the document tree into LaTeX files. List of tuples 188 | # (source start file, target name, title, author, documentclass [howto/manual]). 189 | latex_documents = [ 190 | ('index', 'machinist.tex', u'machinist Documentation', 191 | u'Jean-Paul Calderone', 'manual'), 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | #latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | #latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | #latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | #latex_show_urls = False 207 | 208 | # Documents to append as an appendix to all manuals. 209 | #latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | #latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ('index', 'machinist', u'machinist Documentation', 221 | [u'Jean-Paul Calderone'], 1) 222 | ] 223 | 224 | # If true, show URL addresses after external links. 225 | #man_show_urls = False 226 | 227 | 228 | # -- Options for Texinfo output ------------------------------------------------ 229 | 230 | # Grouping the document tree into Texinfo files. List of tuples 231 | # (source start file, target name, title, author, 232 | # dir menu entry, description, category) 233 | texinfo_documents = [ 234 | ('index', 'machinist', u'machinist Documentation', 235 | u'Jean-Paul Calderone', 'machinist', 'One line description of project.', 236 | 'Miscellaneous'), 237 | ] 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #texinfo_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #texinfo_domain_indices = True 244 | 245 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 246 | #texinfo_show_urls = 'footnote' 247 | 248 | # Configure the intersphinx extension. 249 | # 250 | # Here is where configuration would go to make it possible to link to Twisted's 251 | # API docs - after https://twistedmatrix.com/trac/ticket/7459 is resolved. 252 | intersphinx_mapping = { 253 | 'http://docs.python.org/': None, 254 | } 255 | 256 | # Let doctests import the examples. 257 | doctest_path = [os.path.dirname(__file__)] 258 | -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | turnstile 8 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. machinist documentation master file, created by 2 | sphinx-quickstart on Fri May 23 13:53:35 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to machinist's documentation! 7 | ===================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | introduction 15 | basics 16 | examples 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | 25 | -------------------------------------------------------------------------------- /doc/introduction.rst: -------------------------------------------------------------------------------- 1 | The State Machine Construction Toolkit 2 | ====================================== 3 | 4 | Machinist's aim is to make it easy to structure your code as an explicit state machine. 5 | 6 | State machines are defined by supplying Machinist with four things: 7 | 8 | 1. A set of states. 9 | 2. A set of inputs. 10 | 3. A set of outputs. 11 | 4. A set of transitions. 12 | 13 | State machines are represented by an object with a ``receive`` method which accepts an input object. 14 | The input object is either an object from the set of inputs or an object related to one of those objects in a certain way (more on that later). 15 | When an input is received by the state machine, its state is updated and outputs are generated according to the defined transitions. 16 | 17 | If this sounds great to you then you might want to jump ahead to the :ref:`Basic Usage` documentation. 18 | Otherwise, read on. 19 | 20 | 21 | Benefits of Explicit State Machines 22 | =================================== 23 | 24 | All software is an implementation of a state machine. 25 | The memory associated with a running program represents its current state. 26 | The executable code defines what transitions are possible. 27 | Myriad inputs and outputs exist in the form of: 28 | 29 | * data read from file descriptors. 30 | * signals or GUI events such as button clicks. 31 | * data which is rendered onto displays. 32 | * sound which is created by speakers. 33 | 34 | The difference between an explicit state machine and software written without a state machine (let's call this *implicit state machine* software or *ism* software) mostly comes down to what it is easy to learn about the state machine being represented. 35 | 36 | 37 | States 38 | ------ 39 | 40 | In the explicit state machine all of the states have been enumerated and can be learned at a glance. 41 | In :abbr:`ism (implicit state machine)` software it is impractical to enumerate the states: imagine a program with just one piece of memory, a 16 bit integer. 42 | There are 2\ :superscript:`16` (65536) states in this program. 43 | Without reading all the program that manipulates this state it's impossible to know which of them are important or how they might interact. 44 | Extend your imagination to any real piece of software which might operate on dozens, hundreds, or thousands of megabytes of memory. 45 | Consider the number of states this amount of memory implies. 46 | It's not just difficult to make sense of this collection of states, it is practically impossible. 47 | 48 | Contrast this with an explicit state machine where each state is given a name and put in a list. 49 | The explicit state machine version of that program with a 16 bit integer will make it obvious that only three of the values (states) it can take on are used. 50 | 51 | 52 | Inputs and Outputs 53 | ------------------ 54 | 55 | In the explicit state machine all of the inputs and outputs are also completely enumerated. 56 | In :abbr:`ism (implicit state machine)` software these are usually only defined by the implementation accepting or producing them. 57 | This means there is just one way to determine what inputs are accepted and what outputs are produced: 58 | read the implementation. 59 | If you're lucky, someone will have done this already and produced some API documentation. 60 | If you're doubly lucky, the implementation won't have changed since they did this. 61 | 62 | Contrast this with an explicit state machine where the implementation is derived from the explicit list of inputs and outputs. 63 | The implementation cannot diverge because it is a function of the declaration. 64 | 65 | 66 | Transitions 67 | ----------- 68 | 69 | Once again, transitions are completely enumerated in the definition of an explicit state machine. 70 | A single transition specifies that when a specific input is received while the state machine is in a specific state a specific output is produced and the state machine changes to a specific new state. 71 | A collection of transitions completely specifies how the state machine reacts to inputs and how its future behavior is changed by those inputs. 72 | In :abbr:`ism (implicit state machine)` software it is conventional to define a constellation of flags to track the state of the program. 73 | It is left up to the programmer to build and remember the cartesian product of these flags in their head. 74 | There are also usually illegal flag combinations which the program is never *supposed* to encounter. 75 | These are either left as traps to future programmers or the implementer must take the tedious steps of building guards against them arising. 76 | All of this results in greater complexity to handle scenarios which are never even supposed to be encountered. 77 | 78 | Contrast this with an explicit state machine where those flags are replaced by the state of the state machine. 79 | The valid states are completely enumerated and there is no need to look at a handful of flags to determine how the program will behave. 80 | Instead of adding complexity to handle impossible cases, those cases are excluded simply by *not* defining an explicit state to represent them. 81 | 82 | -------------------------------------------------------------------------------- /doc/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. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\machinist.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\machinist.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /doc/turnstile-complex.py: -------------------------------------------------------------------------------- 1 | # 2 | # Note: This is not a runnable example! It depends on a fictional library for 3 | # manipulating some imaginary hardware that might drive a real-world, physical 4 | # turnstile. However, the machinist-using code in this file is intended to be 5 | # realistic and correct. 6 | # 7 | 8 | import time 9 | 10 | from twisted.python.constants import Names, NamedConstant 11 | 12 | from machinist import ( 13 | TransitionTable, MethodSuffixOutputer, constructFiniteStateMachine, 14 | ) 15 | 16 | from turnstilelib import TurnstileController 17 | 18 | class TurnstileInput(Names): 19 | FARE_PAID = NamedConstant() 20 | ARM_UNLOCKED = NamedConstant() 21 | ARM_TURNED = NamedConstant() 22 | ARM_LOCKED = NamedConstant() 23 | 24 | class TurnstileOutput(Names): 25 | ENGAGE_LOCK = NamedConstant() 26 | DISENGAGE_LOCK = NamedConstant() 27 | 28 | class TurnstileState(Names): 29 | LOCKED = NamedConstant() 30 | UNLOCKED = NamedConstant() 31 | ACTIVE = NamedConstant() 32 | 33 | table = TransitionTable() 34 | table = table.addTransitions( 35 | TurnstileState.UNLOCKED, { 36 | TurnstileInput.ARM_TURNED: 37 | ([TurnstileOutput.ENGAGE_LOCK], TurnstileState.ACTIVE), 38 | }) 39 | table = table.addTransitions( 40 | TurnstileState.ACTIVE, { 41 | TurnstileInput.ARM_LOCKED: ([], TurnstileState.LOCKED), 42 | TurnstileInput.ARM_UNLOCKED: ([], TurnstileState.UNLOCKED), 43 | }) 44 | table = table.addTransitions( 45 | TurnstileState.LOCKED, { 46 | TurnstileInput.FARE_PAID: 47 | ([TurnstileOutput.DISENGAGE_LOCK], TurnstileState.ACTIVE), 48 | }) 49 | 50 | class Turnstile(object): 51 | def __init__(self, hardware): 52 | self._hardware = hardware 53 | 54 | def output_ENGAGE_LOCK(self, engage): 55 | self._hardware.engageLock() 56 | 57 | def output_DISENGAGE_LOCK(self, disengage): 58 | self._hardware.disengageLock() 59 | 60 | def main(): 61 | hardware = TurnstileController(digitalPin=0x13) 62 | turnstileFSM = constructFiniteStateMachine( 63 | inputs=TurnstileInput, 64 | outputs=TurnstileOutput, 65 | states=TurnstileState, 66 | table=table, 67 | initial=TurnstileState.LOCKED, 68 | richInputs=[], 69 | inputContext={}, 70 | world=MethodSuffixOutputer(Turnstile(hardware)), 71 | ) 72 | while True: 73 | if hardware.paymentMade(): 74 | hardware.resetNotification() 75 | turnstileFSM.receive(TurnstileInput.FARE_PAID) 76 | elif hardware.armTurned(): 77 | hardware.resetNotification() 78 | turnstileFSM.receive(TurnstileInput.ARM_TURNED) 79 | elif hardware.finishedLocking(): 80 | hardware.resetNotification() 81 | turnstileFSM.receive(TurnstileInput.ARM_LOCKED) 82 | elif hardware.finishedUnlocking(): 83 | hardware.resetNotification() 84 | turnstileFSM.receive(TurnstileInput.ARM_UNLOCKED) 85 | else: 86 | time.sleep(0.1) 87 | 88 | -------------------------------------------------------------------------------- /doc/turnstile.py: -------------------------------------------------------------------------------- 1 | # This example is marked up for piecewise inclusion in basics.rst. All code 2 | # relevant to machinist must fall between inclusion markers (so, for example, 3 | # __future__ imports may be outside such markers; also this is required by 4 | # Python syntax). If you damage the markers the documentation will silently 5 | # break. So try not to do that. 6 | 7 | from __future__ import print_function 8 | 9 | # begin setup 10 | from twisted.python.constants import Names, NamedConstant 11 | 12 | class Input(Names): 13 | FARE_PAID = NamedConstant() 14 | ARM_UNLOCKED = NamedConstant() 15 | ARM_TURNED = NamedConstant() 16 | ARM_LOCKED = NamedConstant() 17 | 18 | class Output(Names): 19 | ENGAGE_LOCK = NamedConstant() 20 | DISENGAGE_LOCK = NamedConstant() 21 | 22 | class State(Names): 23 | LOCKED = NamedConstant() 24 | UNLOCKED = NamedConstant() 25 | ACTIVE = NamedConstant() 26 | # end setup 27 | 28 | # begin table def 29 | from machinist import TransitionTable 30 | 31 | table = TransitionTable() 32 | # end table def 33 | 34 | # begin first transition 35 | table = table.addTransition( 36 | State.LOCKED, Input.FARE_PAID, [Output.DISENGAGE_LOCK], State.ACTIVE) 37 | # end first transition 38 | 39 | # begin second transition 40 | table = table.addTransition( 41 | State.UNLOCKED, Input.ARM_TURNED, [Output.ENGAGE_LOCK], State.ACTIVE) 42 | # end second transition 43 | 44 | # begin last transitions 45 | table = table.addTransitions( 46 | State.ACTIVE, { 47 | Input.ARM_UNLOCKED: ([], State.UNLOCKED), 48 | Input.ARM_LOCKED: ([], State.LOCKED), 49 | }) 50 | # end last transitions 51 | 52 | # begin outputer 53 | from machinist import MethodSuffixOutputer 54 | 55 | class Outputer(object): 56 | def output_ENGAGE_LOCK(self, engage): 57 | print("Engaging the lock.") 58 | 59 | def output_DISENGAGE_LOCK(self, disengage): 60 | print("Disengaging the lock.") 61 | 62 | outputer = MethodSuffixOutputer(Outputer()) 63 | # end outputer 64 | 65 | # begin construct 66 | from machinist import constructFiniteStateMachine 67 | 68 | turnstile = constructFiniteStateMachine( 69 | inputs=Input, 70 | outputs=Output, 71 | states=State, 72 | table=table, 73 | initial=State.LOCKED, 74 | richInputs=[], 75 | inputContext={}, 76 | world=outputer, 77 | ) 78 | # end construct 79 | 80 | # begin inputs 81 | def cycle(): 82 | turnstile.receive(Input.FARE_PAID) 83 | turnstile.receive(Input.ARM_UNLOCKED) 84 | turnstile.receive(Input.ARM_TURNED) 85 | turnstile.receive(Input.ARM_LOCKED) 86 | # end inputs 87 | 88 | if __name__ == '__main__': 89 | cycle() 90 | -------------------------------------------------------------------------------- /doc/turnstile.rst: -------------------------------------------------------------------------------- 1 | .. _`turnstile-example`: 2 | 3 | Model a Turnstile 4 | ================= 5 | 6 | .. literalinclude:: turnstile.py 7 | -------------------------------------------------------------------------------- /machinist/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | # -*- test-case-name: machinist -*- 3 | 4 | """ 5 | General tools for building finite state machines. 6 | 7 | A finite state machine maps simple, symbolic inputs (called the I{input 8 | alphabet}) to simple, symbolic outputs (called the I{output alphabet}). In 9 | this context, I{symbolic} means that nothing differentiates the values from 10 | each other apart from their identity. There is no additional state attached to 11 | inputs or outputs. 12 | 13 | The finite state machine does this mapping in a stateful way. The machine has 14 | a I{current} state and the mapping between inputs and outputs may be different 15 | for each possible value of that current state. Current states are also 16 | represented as simple, symbolic values. 17 | 18 | The mapping from inputs to outputs also includes definitions for state 19 | transitions. The current state of the machine changes to a new value each time 20 | an input is mapped to an output (though the new value may be the same as the 21 | old value). 22 | 23 | For this implementation of finite state machines, L{twisted.python.constants} 24 | is used for the definitions of all inputs, outputs, and states. All inputs 25 | must be defined as L{NamedConstant} attributes of a L{Names} subclass. All 26 | outputs must be similarly defined on another class. And likewise, all states 27 | must be defined this way as well. 28 | 29 | For example, the symbols for an extremely simple finite state machine might be 30 | defined like this:: 31 | 32 | from twisted.python.constants import Names, NamedConstant 33 | 34 | class Input(Names): 35 | foo = NamedConstant() 36 | 37 | class Output(Names): 38 | bar = NamedConstant() 39 | 40 | class State(Names): 41 | baz = NamedConstant() 42 | 43 | A transition table is also required to construct a new finite state machine. 44 | The transition table is a L{dict} that has all of the possible states as its 45 | keys. Associated with each key is a value that is another L{dict}. These 46 | inner L{dict} have inputs as their keys. It is not required that all input 47 | symbols be present in all of these inner L{dict}s (however it will be an error 48 | if an input is received and missing). The values in these inner L{dict} are 49 | L{Transition} instances that define both the output associated with the 50 | transition as well as the next state which will become the current state of the 51 | machine when this transition occurs. 52 | 53 | For example, a transition table using the inputs, outputs, and states defined 54 | in the example above might be defined like this:: 55 | 56 | transitions = TransitionTable({ 57 | State.baz: { 58 | Input.foo: Transition([Output.bar], State.baz), 59 | }, 60 | }) 61 | 62 | Taken all together these will define a state machine with one possible state 63 | (I{baz}) which accepts one possible input (I{foo}). When I{foo} is received 64 | the machine will output I{bar} and transition to (remain in) the I{baz} state. 65 | 66 | Notice that the output from a transition is actually a list of symbols from the 67 | output alphabet - not just a single output. This is intended to allow more 68 | expressive side-effects without requiring a (possibly combinatorial) explosion 69 | of the number of symbols in the output alphabet. The order of outputs in this 70 | list may or may not be significant: it is up to the L{IOutputExecutor} 71 | implementation paired with the state machine. 72 | 73 | L{constructFiniteStateMachine} is the only constructor supplied for creating a 74 | state machine from a transition table (and some other inputs). This 75 | constructor performs some correctness checking of the transition table and 76 | refuses to construct a state machine that has certain statically detectable 77 | errors. When it does construct a state machine, it constructs one with a 78 | couple extra behaviors beyond the basic state machine features described above. 79 | 80 | First, the resulting state machine automatically logs all inputs, outputs, and 81 | state transitions it undergoes. 82 | 83 | Second, the C{world} argument to L{constructFiniteStateMachine} (an object 84 | which provides L{IOutputExecutor}) is passed each output from each state 85 | transition. C{world} is intended to encapsulate all of side-effects on the 86 | application the state machine is part of. 87 | 88 | The L{IFiniteStateMachine} implemented in this module also logs information 89 | using L{eliot}. The following events are logged: 90 | 91 | - the initialization of a new finite state machine 92 | - the receipt of an input by a finite state machine 93 | - the finalization of a finite state machine (by entering a terminal state) 94 | 95 | Give the code a try (or read it) to see the particular fields these messages 96 | have. 97 | """ 98 | 99 | __all__ = [ 100 | "IFiniteStateMachine", "IOutputExecutor", "IRichInput", 101 | "StateMachineDefinitionError", "ExtraTransitionState", 102 | "MissingTransitionState", "ExtraTransitionInput", 103 | "MissingTransitionInput", "ExtraTransitionOutput", 104 | "MissingTransitionOutput", "ExtraTransitionNextState", 105 | "MissingTransitionNextState", "InvalidInitialState", 106 | "ExtraInputContext", 107 | "UnhandledInput", "IllegalInput", "WrongState", 108 | 109 | "Transition", "TransitionTable", "trivialInput", 110 | "constructFiniteStateMachine", 111 | "MethodSuffixOutputer", "stateful", 112 | 113 | "LOG_FSM_INITIALIZE", 114 | "LOG_FSM_TRANSITION", 115 | 116 | "__version__", 117 | ] 118 | 119 | from ._interface import ( 120 | IFiniteStateMachine, IOutputExecutor, IRichInput, 121 | ) 122 | 123 | try: 124 | from ._logging import ( 125 | LOG_FSM_INITIALIZE, 126 | LOG_FSM_TRANSITION, 127 | ) 128 | except ImportError: 129 | LOG_FSM_INITIALIZE = LOG_FSM_TRANSITION = None 130 | 131 | from ._fsm import ( 132 | StateMachineDefinitionError, ExtraTransitionState, 133 | MissingTransitionState, ExtraTransitionInput, 134 | MissingTransitionInput, ExtraTransitionOutput, 135 | MissingTransitionOutput, ExtraTransitionNextState, 136 | MissingTransitionNextState, InvalidInitialState, 137 | ExtraInputContext, 138 | UnhandledInput, IllegalInput, WrongState, 139 | 140 | Transition, TransitionTable, trivialInput, constructFiniteStateMachine, 141 | MethodSuffixOutputer, stateful, 142 | ) 143 | 144 | from ._version import get_versions 145 | __version__ = get_versions()['version'] 146 | del get_versions 147 | -------------------------------------------------------------------------------- /machinist/_fsm.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | # -*- test-case-name: machinist.test.test_fsm -*- 3 | 4 | """ 5 | Implementation details for machinist's public interface. 6 | """ 7 | 8 | from zope.interface import implementer 9 | from zope.interface.exceptions import DoesNotImplement 10 | 11 | 12 | from twisted.python.util import FancyStrMixin, FancyEqMixin 13 | 14 | from ._interface import IFiniteStateMachine, IOutputExecutor, IRichInput 15 | 16 | try: 17 | from ._logging import Logger, FiniteStateLogger 18 | except ImportError: 19 | LOGGER = None 20 | else: 21 | LOGGER = Logger() 22 | 23 | 24 | class StateMachineDefinitionError(Exception): 25 | """ 26 | This is the base class for exceptions relating to problems in the 27 | definition of a state machine (problems that will lead to the construction 28 | of a state machine from said definition to fail). 29 | """ 30 | 31 | 32 | 33 | class ExtraTransitionState(StateMachineDefinitionError): 34 | """ 35 | There are extra states in the transition table which are not explicitly 36 | defined by the state L{Names} subclass. 37 | """ 38 | 39 | 40 | 41 | class MissingTransitionState(StateMachineDefinitionError): 42 | """ 43 | There are states explicitly defined by the state L{Names} subclass which 44 | are not defined in the transition table. 45 | """ 46 | 47 | 48 | 49 | class ExtraTransitionInput(StateMachineDefinitionError): 50 | """ 51 | There are extra inputs handled by the transition table which are not explicitly 52 | defined by the input L{Names} subclass. 53 | """ 54 | 55 | 56 | 57 | class MissingTransitionInput(StateMachineDefinitionError): 58 | """ 59 | There are inputs explicitly defined by the input L{Names} subclass which 60 | are not handled by any state in the transition table. 61 | """ 62 | 63 | 64 | 65 | class ExtraTransitionOutput(StateMachineDefinitionError): 66 | """ 67 | There are extra outputs defined by the transition table which are not explicitly 68 | defined by the output L{Names} subclass. 69 | """ 70 | 71 | 72 | 73 | class MissingTransitionOutput(StateMachineDefinitionError): 74 | """ 75 | There are outputs explicitly defined by the output L{Names} subclass which 76 | are not generated by any state transition in the transition table. 77 | """ 78 | 79 | 80 | 81 | class ExtraTransitionNextState(StateMachineDefinitionError): 82 | """ 83 | There are extra states as next state values in the transition table which 84 | are not explicitly defined by the state L{Names} subclass. 85 | """ 86 | 87 | 88 | 89 | class MissingTransitionNextState(StateMachineDefinitionError): 90 | """ 91 | There are states explicitly defined by the state L{Names} subclass which 92 | cannot be reached by any input in any state defined by the transition table 93 | and are also not the initial state. 94 | """ 95 | 96 | 97 | 98 | class InvalidInitialState(StateMachineDefinitionError): 99 | """ 100 | The initial state of the state machine is given as a value which is not 101 | explicitly defined by the state L{Names} subclass. 102 | """ 103 | 104 | 105 | 106 | class ExtraInputContext(StateMachineDefinitionError): 107 | """ 108 | An input context is defined for a value which is not explicitly defined by 109 | the output L{Names} subclass. 110 | """ 111 | 112 | 113 | 114 | class UnhandledInput(Exception): 115 | """ 116 | The state machine received an input for which no transition was defined in 117 | the machine's current state. 118 | """ 119 | 120 | 121 | 122 | class IllegalInput(Exception): 123 | """ 124 | The state machine received an input which is not part of the machine's 125 | input alphabet. 126 | """ 127 | 128 | 129 | 130 | class Transition(FancyStrMixin, FancyEqMixin, object): 131 | """ 132 | A L{Transition} represents an output produced and the next state to assume 133 | by a L{IFiniteStateMachine} on receipt of a particular input in a 134 | particular state. 135 | """ 136 | compareAttributes = showAttributes = ["output", "nextState"] 137 | 138 | def __init__(self, output, nextState): 139 | self.output = output 140 | self.nextState = nextState 141 | 142 | 143 | 144 | class TransitionTable(object): 145 | """ 146 | A L{TransitionTable} contains the definitions of all state transitions for 147 | a L{IFiniteStateMachine}. 148 | 149 | @ivar table: L{dict} mapping symbols from C{states} to L{dict} mapping 150 | symbols from C{inputs} to L{Transition} instances giving the output and 151 | next state when the corresponding input is received. 152 | 153 | @note: L{TransitionTable} has no methods which mutate instances of it. 154 | Instances are meant to be immutable to simplify reasoning about state 155 | machines and to facilitate sharing of transition definitions. 156 | """ 157 | def __init__(self, table=None): 158 | if table is None: 159 | table = {} 160 | self.table = table 161 | 162 | 163 | def _copy(self): 164 | """ 165 | Create a new L{TransitionTable} just like this one using a copy of the 166 | underlying transition table. 167 | 168 | @rtype: L{TransitionTable} 169 | """ 170 | table = {} 171 | for existingState, existingOutputs in self.table.items(): 172 | table[existingState] = {} 173 | for (existingInput, existingTransition) in existingOutputs.items(): 174 | table[existingState][existingInput] = existingTransition 175 | return TransitionTable(table) 176 | 177 | 178 | def addTransition(self, state, input, output, nextState): 179 | """ 180 | Create a new L{TransitionTable} with all the same transitions as this 181 | L{TransitionTable} plus a new transition. 182 | 183 | @param state: The state for which the new transition is defined. 184 | @param input: The input that triggers the new transition. 185 | @param output: The output produced by the new transition. 186 | @param nextState: The state that will follow the new transition. 187 | 188 | @return: The newly created L{TransitionTable}. 189 | """ 190 | return self.addTransitions(state, {input: (output, nextState)}) 191 | 192 | 193 | def addTransitions(self, state, transitions): 194 | """ 195 | Create a new L{TransitionTable} with all the same transitions as this 196 | L{TransitionTable} plus a number of new transitions. 197 | 198 | @param state: The state for which the new transitions are defined. 199 | @param transitions: A L{dict} mapping inputs to output, nextState 200 | pairs. Each item from this L{dict} will define a new transition in 201 | C{state}. 202 | 203 | @return: The newly created L{TransitionTable}. 204 | """ 205 | table = self._copy() 206 | state = table.table.setdefault(state, {}) 207 | for (input, (output, nextState)) in transitions.items(): 208 | state[input] = Transition(output, nextState) 209 | return table 210 | 211 | 212 | def addTerminalState(self, state): 213 | """ 214 | Create a new L{TransitionTable} with all of the same transitions as 215 | this L{TransitionTable} plus a new state with no transitions. 216 | 217 | @param state: The new state to include in the new table. 218 | 219 | @return: The newly created L{TransitionTable}. 220 | """ 221 | table = self._copy() 222 | table.table[state] = {} 223 | return table 224 | 225 | 226 | 227 | def _missingExtraCheck(given, required, extraException, missingException): 228 | """ 229 | If the L{sets} C{required} and C{given} do not contain the same 230 | elements raise an exception describing how they are different. 231 | 232 | @param given: The L{set} of elements that was actually given. 233 | @param required: The L{set} of elements that must be given. 234 | 235 | @param extraException: An exception to raise if there are elements in 236 | C{given} that are not in C{required}. 237 | @param missingException: An exception to raise if there are elements in 238 | C{required} that are not in C{given}. 239 | 240 | @return: C{None} 241 | """ 242 | extra = given - required 243 | if extra: 244 | raise extraException(extra) 245 | 246 | missing = required - given 247 | if missing: 248 | raise missingException(missing) 249 | 250 | 251 | 252 | def constructFiniteStateMachine(inputs, outputs, states, table, initial, 253 | richInputs, inputContext, world, 254 | logger=LOGGER): 255 | """ 256 | Construct a new finite state machine from a definition of its states. 257 | 258 | @param inputs: Definitions of all input symbols the resulting machine will 259 | need to handle, as a L{twisted.python.constants.Names} subclass. 260 | 261 | @param outputs: Definitions of all output symbols the resulting machine is 262 | allowed to emit, as a L{twisted.python.constants.Names} subclass. 263 | 264 | @param states: Definitions of all possible states the resulting machine 265 | will be capable of inhabiting, as a L{twisted.python.constants.Names} 266 | subclass. 267 | 268 | @param table: The state transition table, defining which output and next 269 | state results from the receipt of any and all inputs in any and all 270 | states. 271 | @type table: L{TransitionTable} 272 | 273 | @param initial: The state the machine will start in (one of the symbols 274 | from C{states}). 275 | 276 | @param richInputs: A L{list} of types which correspond to each of the input 277 | symbols from C{inputs}. 278 | @type richInputs: L{list} of L{IRichInput} I{providers} 279 | 280 | @param inputContext: A L{dict} mapping output symbols to L{Interface} 281 | subclasses describing the requirements of the inputs which lead to 282 | them. 283 | 284 | @param world: An object responsible for turning FSM outputs into observable 285 | side-effects. 286 | @type world: L{IOutputExecutor} provider 287 | 288 | @param logger: The logger to which to write messages. 289 | @type logger: L{eliot.ILogger} or L{NoneType} if there is no logger. 290 | 291 | @return: An L{IFiniteStateMachine} provider 292 | """ 293 | table = table.table 294 | 295 | _missingExtraCheck( 296 | set(table.keys()), set(states.iterconstants()), 297 | ExtraTransitionState, MissingTransitionState) 298 | 299 | _missingExtraCheck( 300 | set(i for s in table.values() for i in s), set(inputs.iterconstants()), 301 | ExtraTransitionInput, MissingTransitionInput) 302 | 303 | _missingExtraCheck( 304 | set(output for s in table.values() for transition in s.values() for output in transition.output), 305 | set(outputs.iterconstants()), 306 | ExtraTransitionOutput, MissingTransitionOutput) 307 | 308 | try: 309 | _missingExtraCheck( 310 | set(transition.nextState for s in table.values() for transition in s.values()), 311 | set(states.iterconstants()), 312 | ExtraTransitionNextState, MissingTransitionNextState) 313 | except MissingTransitionNextState as e: 314 | if e.args != ({initial},): 315 | raise 316 | 317 | if initial not in states.iterconstants(): 318 | raise InvalidInitialState(initial) 319 | 320 | extraInputContext = set(inputContext) - set(outputs.iterconstants()) 321 | if extraInputContext: 322 | raise ExtraInputContext(extraInputContext) 323 | 324 | _checkConsistency(richInputs, table, inputContext) 325 | 326 | fsm = _FiniteStateMachine(inputs, outputs, states, table, initial) 327 | executor = IOutputExecutor(world) 328 | interpreter = _FiniteStateInterpreter( 329 | tuple(richInputs), inputContext, fsm, executor) 330 | if logger is not None: 331 | interpreter = FiniteStateLogger( 332 | interpreter, logger, executor.identifier()) 333 | return interpreter 334 | 335 | 336 | 337 | def _checkConsistency(richInputs, fsm, inputContext): 338 | """ 339 | Verify that the outputs that can be generated by fsm have their 340 | requirements satisfied by the given rich inputs. 341 | 342 | @param richInputs: A L{list} of all of the types which will serve as rich 343 | inputs to an L{IFiniteStateMachine}. 344 | @type richInputs: L{list} of L{IRichInput} providers 345 | 346 | @param fsm: The L{IFiniteStateMachine} to which these rich inputs are to be 347 | delivered. 348 | 349 | @param inputContext: A L{dict} mapping output symbols to L{Interface} 350 | subclasses. Rich inputs which result in these outputs being produced 351 | by C{fsm} must provide the corresponding interface. 352 | 353 | @raise DoesNotImplement: If any of the rich input types fails to implement 354 | the interfaces required by the outputs C{fsm} can produce when they are 355 | received. 356 | """ 357 | for richInput in richInputs: 358 | for state in fsm: 359 | for input in fsm[state]: 360 | if richInput.symbol() == input: 361 | # This rich input will be supplied to represent this input 362 | # symbol in this state. Check to see if it satisfies the 363 | # output requirements. 364 | outputs = fsm[state][input].output 365 | for output in outputs: 366 | try: 367 | required = inputContext[output] 368 | except KeyError: 369 | continue 370 | # Consider supporting non-interface based checking in 371 | # the future: extend this to also allow 372 | # issubclass(richInput, required) 373 | if required.implementedBy(richInput): 374 | continue 375 | raise DoesNotImplement( 376 | "%r not implemented by %r, " 377 | "required by %r in state %r" % ( 378 | required, richInput, 379 | input, state)) 380 | 381 | 382 | 383 | def _symbol(which): 384 | # Work-around for Twisted #5797 - fixed in 13.0.0 385 | return classmethod(lambda cls: which) 386 | 387 | 388 | 389 | def trivialInput(symbol): 390 | """ 391 | Create a new L{IRichInput} implementation for the given input symbol. 392 | 393 | This creates a new type object and is intended to be used at module scope 394 | to define rich input types. Generally, only one use per symbol should be 395 | required. For example:: 396 | 397 | Apple = trivialInput(Fruit.apple) 398 | 399 | @param symbol: A symbol from some state machine's input alphabet. 400 | 401 | @return: A new type object usable as a rich input for the given symbol. 402 | @rtype: L{type} 403 | """ 404 | return implementer(IRichInput)(type( 405 | symbol.name.title(), (FancyStrMixin, object), { 406 | "symbol": _symbol(symbol), 407 | })) 408 | 409 | 410 | 411 | @implementer(IFiniteStateMachine) 412 | class _FiniteStateMachine(object): 413 | """ 414 | A L{_FiniteStateMachine} tracks the core logic of a finite state machine: 415 | recording the current state and mapping inputs to outputs and next states. 416 | 417 | @ivar inputs: See L{constructFiniteStateMachine} 418 | @ivar outputs: See L{constructFiniteStateMachine} 419 | @ivar states: See L{constructFiniteStateMachine} 420 | @ivar table: See L{constructFiniteStateMachine} 421 | @ivar initial: See L{constructFiniteStateMachine} 422 | 423 | @ivar state: The current state of this FSM. 424 | @type state: L{NamedConstant} from C{states} 425 | """ 426 | def __init__(self, inputs, outputs, states, table, initial): 427 | self.inputs = inputs 428 | self.outputs = outputs 429 | self.states = states 430 | self.table = table 431 | self.initial = initial 432 | self.state = initial 433 | 434 | 435 | def receive(self, input): 436 | current = self.table[self.state] 437 | 438 | if input not in self.inputs.iterconstants(): 439 | raise IllegalInput(input) 440 | 441 | try: 442 | transition = current[input] 443 | except KeyError: 444 | raise UnhandledInput(self.state, input) 445 | 446 | self.state = transition.nextState 447 | return transition.output 448 | 449 | 450 | def _isTerminal(self, state): 451 | """ 452 | Determine whether or not the given state is a terminal state in this 453 | state machine. Terminal states have no transitions to other states. 454 | Additionally, terminal states have no outputs. 455 | 456 | @param state: The state to examine. 457 | 458 | @return: C{True} if the state is terminal, C{False} if it is not. 459 | @rtype: L{bool} 460 | """ 461 | # This is private with the idea that maybe terminal should be defined 462 | # differently eventually - perhaps by accepting an explicit set of 463 | # terminal states in constructFiniteStateMachine. 464 | # https://www.pivotaltracker.com/story/show/59999580 465 | return all( 466 | transition.output == [] and transition.nextState == state 467 | for (input, transition) 468 | in self.table[state].iteritems()) 469 | 470 | 471 | 472 | @implementer(IFiniteStateMachine) 473 | class _FiniteStateInterpreter(object): 474 | """ 475 | A L{_FiniteStateInterpreter} translates between the "real world" - which 476 | has symbolic or rich inputs and non-pure outputs - and a finite state 477 | machine which accepts only symbolic inputs and produces only symbolic 478 | outputs. 479 | 480 | @ivar _richInputs: All the types of rich inputs that are allowed. 481 | @type _richInputs: L{tuple} of L{type} 482 | 483 | @ivar _inputContext: Adapters from rich input types to whatever types are 484 | required by the output executor. The context passed to 485 | L{IOutputExecutor.output} is constructed by calling an adapter from 486 | this dictionary with the rich input that resulted in the output. 487 | @type _inputContext: L{dict} mapping L{type} to one-argument callables 488 | 489 | @ivar _fsm: The underlying, pure state machine. 490 | @type _fsm: L{IFiniteStateMachine} provider 491 | 492 | @ivar _world: The L{IOutputExecutor} provider this interpreter will drive 493 | with outputs from C{_fsm}. 494 | """ 495 | 496 | def __repr__(self): 497 | return "" % (self._world,) 498 | 499 | @property 500 | def state(self): 501 | return self._fsm.state 502 | 503 | 504 | def __init__(self, richInputs, inputContext, fsm, world): 505 | self._richInputs = richInputs 506 | self._inputContext = inputContext 507 | self._fsm = fsm 508 | self._world = world 509 | 510 | 511 | def receive(self, input): 512 | """ 513 | Deliver an input symbol to the wrapped L{IFiniteStateMachine} from the 514 | given input, which may be symbolic or rich, and deliver the resulting 515 | outputs to the wrapped L{IOutputExecutor}. 516 | 517 | @param input: An input symbol or an L{IRichInput} provider that must 518 | be an instance of one of the rich input types this state machine 519 | was initialized with. 520 | 521 | @return: The output from the wrapped L{IFiniteStateMachine}. 522 | """ 523 | if IRichInput.providedBy(input): 524 | symbol = input.symbol() 525 | if not isinstance(input, self._richInputs): 526 | raise IllegalInput(symbol) 527 | outputs = self._fsm.receive(symbol) 528 | else: 529 | # if it's not a symbol, the underlying FSM will raise IllegalInput 530 | outputs = self._fsm.receive(input) 531 | 532 | for output in outputs: 533 | adapter = self._inputContext.get(output, lambda o: o) 534 | self._world.output(output, adapter(input)) 535 | return outputs 536 | 537 | 538 | def _isTerminal(self, state): 539 | return self._fsm._isTerminal(state) 540 | 541 | 542 | 543 | @implementer(IOutputExecutor) 544 | class MethodSuffixOutputer(object): 545 | """ 546 | A helper to do simple suffixed-method style output dispatching. 547 | 548 | @ivar _identifier: The cached identifier of the wrapped object (cached to 549 | guarantee it never changes). 550 | """ 551 | def __repr__(self): 552 | return "" % (self.original,) 553 | 554 | 555 | def __init__(self, original, prefix="output_"): 556 | """ 557 | @param original: Any old object with a bunch of methods using the specified 558 | method prefix. 559 | 560 | @param prefix: The string prefix which will be used for method 561 | dispatch. For example, if C{"foo_"} is given then to execute the 562 | output symbol I{BAR}, C{original.foo_BAR} will be called. 563 | @type prefix: L{str} 564 | """ 565 | self.original = original 566 | self.prefix = prefix 567 | try: 568 | identifier = self.original.identifier 569 | except AttributeError: 570 | self._identifier = repr(self.original).decode("ascii") 571 | else: 572 | self._identifier = identifier() 573 | 574 | 575 | def identifier(self): 576 | """ 577 | Try delegating to the wrapped object. Provide a simple default if the 578 | wrapped object doesn't implement this method. 579 | """ 580 | return self._identifier 581 | 582 | 583 | def output(self, output, context): 584 | """ 585 | Call the C{prefixNAME} method of the wrapped object - where I{prefix} 586 | is C{self.prefix} and I{NAME} is the name of C{output}. 587 | 588 | @see: L{IOutputExecutor.output} 589 | """ 590 | name = self.prefix + output.name.upper() 591 | method = getattr(self.original, name) 592 | method(context) 593 | 594 | 595 | 596 | class WrongState(Exception): 597 | def __init__(self, stateful, pssr): 598 | Exception.__init__( 599 | self, 600 | "Attribute illegal in state %s, only allowed in states %s" % ( 601 | stateful._getter(pssr), stateful._allowed)) 602 | 603 | 604 | 605 | class stateful(object): 606 | """ 607 | A L{stateful} descriptor can only be used when an associated finite state 608 | machine is in a certain state (or states). 609 | 610 | @ivar _allowed: A L{tuple} of the states in which access to this attribute 611 | is allowed. 612 | 613 | @ivar _getter: A one-argument callable which accepts the object the 614 | descriptor is used on and returns the state of that object for 615 | comparison against C{_allowed}. 616 | 617 | @note: The current implementation strategy for this descriptor type is to 618 | store the application value in the instance C{__dict__} using the 619 | L{stateful} instance as the key. This means that the instance must 620 | have a C{__dict__} (ie, not use C{__slots__}), that non-string keys are 621 | put into C{__dict__}, and that sharing a single L{stateful} instance to 622 | represent two different attributes will produce confusing (probably 623 | incorrect) results. 624 | """ 625 | def __init__(self, getter, *allowed): 626 | self._getter = getter 627 | self._allowed = allowed 628 | 629 | 630 | def __get__(self, obj, cls): 631 | if obj is None: 632 | return self 633 | if self._getter(obj) in self._allowed: 634 | try: 635 | return obj.__dict__[self] 636 | except KeyError: 637 | raise AttributeError() 638 | raise WrongState(self, obj) 639 | 640 | 641 | def __set__(self, obj, value): 642 | if self._getter(obj) not in self._allowed: 643 | raise WrongState(self, obj) 644 | obj.__dict__[self] = value 645 | 646 | 647 | def __delete__(self, obj): 648 | if self._getter(obj) not in self._allowed: 649 | raise WrongState(self, obj) 650 | try: 651 | del obj.__dict__[self] 652 | except KeyError: 653 | raise AttributeError() 654 | -------------------------------------------------------------------------------- /machinist/_interface.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | # -*- test-case-name: machinist -*- 3 | 4 | """ 5 | Interface definitions for machinist. 6 | """ 7 | 8 | __all__ = [ 9 | "IFiniteStateMachine", "IOutputExecutor", "IRichInput", 10 | ] 11 | 12 | from zope.interface import Attribute, Interface 13 | 14 | 15 | class IFiniteStateMachine(Interface): 16 | """ 17 | A finite state machine. 18 | """ 19 | state = Attribute("The current state of the machine.") 20 | 21 | # We could probably make the state, input, and output types part of this 22 | # interface as well. This could facilitate more advanced tools for 23 | # operating on state machines (eg, tools for chaining several together). 24 | 25 | def receive(input): 26 | """ 27 | Accept an input, transition to the next state, and return the generated 28 | output. 29 | 30 | @raise UnhandledInput: If the received input is not acceptable in the 31 | current state. 32 | 33 | @raise IllegalInput: If the received input is not acceptable in any 34 | state by this state machine. 35 | """ 36 | 37 | 38 | 39 | class IOutputExecutor(Interface): 40 | """ 41 | Perform tasks and cause side-effects associated with outputs from a 42 | L{IFiniteStateMachine}. 43 | """ 44 | def identifier(): 45 | """ 46 | Return a constant L{unicode} string that should uniquely identify this 47 | executor. This will be used to uniquely identify log events associated 48 | with it. 49 | 50 | @rtype: L{unicode} 51 | """ 52 | 53 | 54 | def output(output, context): 55 | """ 56 | Perform the operations associated with a particular output. 57 | 58 | @param output: The output symbol to execute. This will always be one 59 | of the output symbols defined by the machine this 60 | L{IOutputExecutor} is being used with. 61 | 62 | @param context: The adapted rich input which triggered the output 63 | symbol. 64 | """ 65 | 66 | 67 | 68 | class IRichInput(Interface): 69 | """ 70 | A L{IRichInput} implementation corresponds to a particular symbol in the 71 | input alphabet of a state machine but may also carry additional 72 | information. 73 | """ 74 | def symbol(): 75 | """ 76 | Return the symbol from the input alphabet to which this input 77 | corresponds. 78 | """ 79 | -------------------------------------------------------------------------------- /machinist/_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | # -*- test-case-name: machinist -*- 3 | 4 | """ 5 | Eliot-based logging functionality for machinist. 6 | """ 7 | 8 | __all__ = [ 9 | "LOG_FSM_INITIALIZE", "LOG_FSM_TRANSITION", 10 | 11 | "FiniteStateLogger", 12 | 13 | "Field", "ActionType", "Logger", 14 | ] 15 | 16 | from twisted.python.components import proxyForInterface 17 | 18 | from eliot import __version__ 19 | 20 | if tuple(int(part) for part in __version__.split(".")[:2]) < (0, 4): 21 | raise ImportError("eliot version %s is too old for machinist") 22 | 23 | from eliot import Field, ActionType, Logger 24 | 25 | from ._interface import IFiniteStateMachine, IRichInput 26 | 27 | def _system(suffix): 28 | return u":".join((u"fsm", suffix)) 29 | 30 | 31 | FSM_IDENTIFIER = Field.forTypes( 32 | u"fsm_identifier", [unicode], 33 | u"An unique identifier for the FSM to which the event pertains.") 34 | FSM_STATE = Field.forTypes( 35 | u"fsm_state", [unicode], u"The state of the FSM prior to the transition.") 36 | FSM_RICH_INPUT = Field.forTypes( 37 | u"fsm_rich_input", [unicode, None], 38 | (u"The string representation of the rich input delivered to the FSM, " 39 | u"or None, if there was no rich input.")) 40 | FSM_INPUT = Field.forTypes( 41 | u"fsm_input", [unicode], 42 | u"The string representation of the input symbol delivered to the FSM.") 43 | FSM_NEXT_STATE = Field.forTypes( 44 | u"fsm_next_state", [unicode], 45 | u"The string representation of the state of the FSM after the transition.") 46 | FSM_OUTPUT = Field.forTypes( 47 | u"fsm_output", [list], # of unicode 48 | u"A list of the string representations of the outputs produced by the " 49 | u"transition.") 50 | FSM_TERMINAL_STATE = Field.forTypes( 51 | u"fsm_terminal_state", [unicode], 52 | u"The string representation of the terminal state entered by the the FSM.") 53 | 54 | LOG_FSM_INITIALIZE = ActionType( 55 | _system(u"initialize"), 56 | [FSM_IDENTIFIER, FSM_STATE], 57 | [FSM_TERMINAL_STATE], 58 | u"A finite state machine was initialized.") 59 | 60 | LOG_FSM_TRANSITION = ActionType( 61 | _system(u"transition"), 62 | [FSM_IDENTIFIER, FSM_STATE, FSM_RICH_INPUT, FSM_INPUT], 63 | [FSM_NEXT_STATE, FSM_OUTPUT], 64 | u"A finite state machine received an input made a transition.") 65 | 66 | 67 | 68 | class FiniteStateLogger(proxyForInterface(IFiniteStateMachine, "_fsm")): 69 | """ 70 | L{FiniteStateLogger} wraps another L{IFiniteStateMachine} provider and adds 71 | to it logging of all state transitions. 72 | """ 73 | def __init__(self, fsm, logger, identifier): 74 | super(FiniteStateLogger, self).__init__(fsm) 75 | self.logger = logger 76 | self.identifier = identifier 77 | self._action = LOG_FSM_INITIALIZE( 78 | logger, fsm_identifier=identifier, fsm_state=unicode(fsm.state)) 79 | 80 | 81 | def receive(self, input): 82 | """ 83 | Add logging of state transitions to the wrapped state machine. 84 | 85 | @see: L{IFiniteStateMachine.receive} 86 | """ 87 | if IRichInput.providedBy(input): 88 | richInput = unicode(input) 89 | symbolInput = unicode(input.symbol()) 90 | else: 91 | richInput = None 92 | symbolInput = unicode(input) 93 | 94 | action = LOG_FSM_TRANSITION( 95 | self.logger, 96 | fsm_identifier=self.identifier, 97 | fsm_state=unicode(self.state), 98 | fsm_rich_input=richInput, 99 | fsm_input=symbolInput) 100 | 101 | with action as theAction: 102 | output = super(FiniteStateLogger, self).receive(input) 103 | theAction.addSuccessFields( 104 | fsm_next_state=unicode(self.state), fsm_output=[unicode(o) for o in output]) 105 | 106 | if self._action is not None and self._isTerminal(self.state): 107 | self._action.addSuccessFields( 108 | fsm_terminal_state=unicode(self.state)) 109 | self._action.finish() 110 | self._action = None 111 | 112 | return output 113 | 114 | 115 | def _isTerminal(self, state): 116 | """ 117 | Determine if a state is terminal. 118 | 119 | A state is terminal if there are no outputs or state changes defined 120 | for any inputs in that state. 121 | 122 | @rtype: L{bool} 123 | """ 124 | # This only works with _FiniteStateMachine since it uses a private 125 | # method of that type. 126 | return self._fsm._isTerminal(state) 127 | -------------------------------------------------------------------------------- /machinist/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (build by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.10 (https://github.com/warner/python-versioneer) 10 | 11 | # these strings will be replaced by git during git-archive 12 | git_refnames = " (HEAD -> master, tag: machinist-0.2.0)" 13 | git_full = "1d1c017ac03be8e737d50af0dfabf31722ddc621" 14 | 15 | 16 | import subprocess 17 | import sys 18 | import errno 19 | 20 | 21 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 22 | assert isinstance(commands, list) 23 | p = None 24 | for c in commands: 25 | try: 26 | # remember shell=False, so use git.cmd on windows, not just git 27 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 28 | stderr=(subprocess.PIPE if hide_stderr 29 | else None)) 30 | break 31 | except EnvironmentError: 32 | e = sys.exc_info()[1] 33 | if e.errno == errno.ENOENT: 34 | continue 35 | if verbose: 36 | print("unable to run %s" % args[0]) 37 | print(e) 38 | return None 39 | else: 40 | if verbose: 41 | print("unable to find command, tried %s" % (commands,)) 42 | return None 43 | stdout = p.communicate()[0].strip() 44 | if sys.version >= '3': 45 | stdout = stdout.decode() 46 | if p.returncode != 0: 47 | if verbose: 48 | print("unable to run %s (error)" % args[0]) 49 | return None 50 | return stdout 51 | 52 | 53 | import sys 54 | import re 55 | import os.path 56 | 57 | def get_expanded_variables(versionfile_abs): 58 | # the code embedded in _version.py can just fetch the value of these 59 | # variables. When used from setup.py, we don't want to import 60 | # _version.py, so we do it with a regexp instead. This function is not 61 | # used from _version.py. 62 | variables = {} 63 | try: 64 | f = open(versionfile_abs,"r") 65 | for line in f.readlines(): 66 | if line.strip().startswith("git_refnames ="): 67 | mo = re.search(r'=\s*"(.*)"', line) 68 | if mo: 69 | variables["refnames"] = mo.group(1) 70 | if line.strip().startswith("git_full ="): 71 | mo = re.search(r'=\s*"(.*)"', line) 72 | if mo: 73 | variables["full"] = mo.group(1) 74 | f.close() 75 | except EnvironmentError: 76 | pass 77 | return variables 78 | 79 | def versions_from_expanded_variables(variables, tag_prefix, verbose=False): 80 | refnames = variables["refnames"].strip() 81 | if refnames.startswith("$Format"): 82 | if verbose: 83 | print("variables are unexpanded, not using") 84 | return {} # unexpanded, so not in an unpacked git-archive tarball 85 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 86 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 87 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 88 | TAG = "tag: " 89 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 90 | if not tags: 91 | # Either we're using git < 1.8.3, or there really are no tags. We use 92 | # a heuristic: assume all version tags have a digit. The old git %d 93 | # expansion behaves like git log --decorate=short and strips out the 94 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 95 | # between branches and tags. By ignoring refnames without digits, we 96 | # filter out many common branch names like "release" and 97 | # "stabilization", as well as "HEAD" and "master". 98 | tags = set([r for r in refs if re.search(r'\d', r)]) 99 | if verbose: 100 | print("discarding '%s', no digits" % ",".join(refs-tags)) 101 | if verbose: 102 | print("likely tags: %s" % ",".join(sorted(tags))) 103 | for ref in sorted(tags): 104 | # sorting will prefer e.g. "2.0" over "2.0rc1" 105 | if ref.startswith(tag_prefix): 106 | r = ref[len(tag_prefix):] 107 | if verbose: 108 | print("picking %s" % r) 109 | return { "version": r, 110 | "full": variables["full"].strip() } 111 | # no suitable tags, so we use the full revision id 112 | if verbose: 113 | print("no suitable tags, using full revision id") 114 | return { "version": variables["full"].strip(), 115 | "full": variables["full"].strip() } 116 | 117 | def versions_from_vcs(tag_prefix, root, verbose=False): 118 | # this runs 'git' from the root of the source tree. This only gets called 119 | # if the git-archive 'subst' variables were *not* expanded, and 120 | # _version.py hasn't already been rewritten with a short version string, 121 | # meaning we're inside a checked out source tree. 122 | 123 | if not os.path.exists(os.path.join(root, ".git")): 124 | if verbose: 125 | print("no .git in %s" % root) 126 | return {} 127 | 128 | GITS = ["git"] 129 | if sys.platform == "win32": 130 | GITS = ["git.cmd", "git.exe"] 131 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 132 | cwd=root) 133 | if stdout is None: 134 | return {} 135 | if not stdout.startswith(tag_prefix): 136 | if verbose: 137 | print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) 138 | return {} 139 | tag = stdout[len(tag_prefix):] 140 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 141 | if stdout is None: 142 | return {} 143 | full = stdout.strip() 144 | if tag.endswith("-dirty"): 145 | full += "-dirty" 146 | return {"version": tag, "full": full} 147 | 148 | 149 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 150 | # Source tarballs conventionally unpack into a directory that includes 151 | # both the project name and a version string. 152 | dirname = os.path.basename(root) 153 | if not dirname.startswith(parentdir_prefix): 154 | if verbose: 155 | print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % 156 | (root, dirname, parentdir_prefix)) 157 | return None 158 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 159 | 160 | tag_prefix = "" 161 | parentdir_prefix = "machinist-" 162 | versionfile_source = "machinist/_version.py" 163 | 164 | def get_versions(default={"version": "unknown", "full": ""}, verbose=False): 165 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 166 | # __file__, we can work backwards from there to the root. Some 167 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 168 | # case we can only use expanded variables. 169 | 170 | variables = { "refnames": git_refnames, "full": git_full } 171 | ver = versions_from_expanded_variables(variables, tag_prefix, verbose) 172 | if ver: 173 | return ver 174 | 175 | try: 176 | root = os.path.abspath(__file__) 177 | # versionfile_source is the relative path from the top of the source 178 | # tree (where the .git directory might live) to this file. Invert 179 | # this to find the root from __file__. 180 | for i in range(len(versionfile_source.split("/"))): 181 | root = os.path.dirname(root) 182 | except NameError: 183 | return default 184 | 185 | return (versions_from_vcs(tag_prefix, root, verbose) 186 | or versions_from_parentdir(parentdir_prefix, root, verbose) 187 | or default) 188 | 189 | -------------------------------------------------------------------------------- /machinist/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScatterHQ/machinist/1d1c017ac03be8e737d50af0dfabf31722ddc621/machinist/test/__init__.py -------------------------------------------------------------------------------- /machinist/test/loglib.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | 3 | """ 4 | Log-related testing helpers. 5 | 6 | Eliot is an optional dependency. This module provides a point of indirection 7 | so that the rest of the test suite can ignore the details of how to work out 8 | whether Eliot is installed or not. 9 | """ 10 | 11 | __all__ = [ 12 | "MessageType", "Logger", "LoggedAction", "LoggedMessage", 13 | 14 | "issuperset", "assertContainsFields", "validateLogging", 15 | 16 | "logSkipReason", 17 | ] 18 | 19 | try: 20 | from eliot import __version__ 21 | if tuple(int(part) for part in __version__.split(".")[:2]) < (0, 4): 22 | raise ImportError("eliot %s is too old" % (__version__,)) 23 | except ImportError as e: 24 | class MessageType(object): 25 | def __init__(self, *args, **kwargs): 26 | pass 27 | 28 | def __call__(self): 29 | return self 30 | 31 | def write(*args, **kwargs): 32 | pass 33 | 34 | Logger = lambda *args, **kwargs: None 35 | 36 | LoggedAction = LoggedMessage = issuperset = assertContainsFields = None 37 | 38 | def validateLogging(*args, **kwargs): 39 | def decorator(function): 40 | def logger(self): 41 | return function(self, None) 42 | return logger 43 | return decorator 44 | 45 | logSkipReason = str(e) 46 | else: 47 | from eliot import MessageType, Logger 48 | from eliot.testing import ( 49 | issuperset, assertContainsFields, LoggedAction, LoggedMessage, 50 | validateLogging, 51 | ) 52 | logSkipReason = None 53 | -------------------------------------------------------------------------------- /machinist/test/test_fsm.py: -------------------------------------------------------------------------------- 1 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 2 | 3 | """ 4 | Tests for L{machinist}. 5 | """ 6 | 7 | from zope.interface import Attribute, Interface, implementer 8 | from zope.interface.exceptions import DoesNotImplement 9 | from zope.interface.verify import verifyObject, verifyClass 10 | 11 | from twisted.python.util import FancyStrMixin 12 | from twisted.python.constants import Names, NamedConstant 13 | from twisted.trial.unittest import TestCase 14 | 15 | from machinist import ( 16 | ExtraTransitionState, MissingTransitionState, 17 | ExtraTransitionInput, MissingTransitionInput, 18 | ExtraTransitionOutput, MissingTransitionOutput, 19 | ExtraTransitionNextState, MissingTransitionNextState, 20 | InvalidInitialState, UnhandledInput, IllegalInput, 21 | ExtraInputContext, 22 | 23 | IRichInput, IFiniteStateMachine, 24 | MethodSuffixOutputer, trivialInput, 25 | Transition, TransitionTable, constructFiniteStateMachine, 26 | 27 | WrongState, stateful, 28 | 29 | LOG_FSM_INITIALIZE, 30 | LOG_FSM_TRANSITION, 31 | ) 32 | 33 | from .loglib import ( 34 | MessageType, Logger, 35 | issuperset, assertContainsFields, LoggedAction, LoggedMessage, 36 | validateLogging, logSkipReason, 37 | ) 38 | 39 | 40 | 41 | class Input(Names): 42 | apple = NamedConstant() 43 | 44 | 45 | 46 | class MoreInput(Names): 47 | apple = NamedConstant() 48 | banana = NamedConstant() 49 | 50 | 51 | class Output(Names): 52 | aardvark = NamedConstant() 53 | 54 | 55 | 56 | class State(Names): 57 | amber = NamedConstant() 58 | 59 | 60 | 61 | class MoreState(Names): 62 | amber = NamedConstant() 63 | blue = NamedConstant() 64 | 65 | 66 | 67 | class IRequiredByAardvark(Interface): 68 | pass 69 | 70 | 71 | 72 | NULL_WORLD = MethodSuffixOutputer(None) 73 | 74 | 75 | 76 | class TransitionTests(TestCase): 77 | """ 78 | Tests for L{Transition}. 79 | """ 80 | def test_str(self): 81 | """ 82 | The string representation of a L{Transition} includes the output and 83 | next state it represents. 84 | """ 85 | self.assertEqual( 86 | "", str(Transition("a", "b"))) 87 | 88 | 89 | def test_repr(self): 90 | """ 91 | The other string representation of a L{Transition} includes the output 92 | and next state it represents. 93 | """ 94 | self.assertEqual( 95 | "", repr(Transition("a", "b"))) 96 | 97 | 98 | def test_equal(self): 99 | """ 100 | Two L{Transition} instances are equal to each other if their attributes 101 | have the same values. 102 | """ 103 | self.assertTrue(Transition("a", "b") == Transition("a", "b")) 104 | 105 | 106 | 107 | class TransitionTableTests(TestCase): 108 | """ 109 | Tests for L{TransitionTable}. 110 | """ 111 | def test_empty(self): 112 | """ 113 | When constructed with no arguments, L{TransitionTable} contains a table 114 | with no states or transitions. 115 | """ 116 | table = TransitionTable() 117 | self.assertEqual({}, table.table) 118 | 119 | 120 | def test_initial(self): 121 | """ 122 | When constructed with a transition table as an argument, 123 | L{TransitionTable} contains exactly that table. 124 | """ 125 | expected = {"foo": {"bar": Transition("baz", "quux")}} 126 | table = TransitionTable(expected) 127 | self.assertIs(expected, table.table) 128 | 129 | 130 | def test_addTransition(self): 131 | """ 132 | L{TransitionTable.addTransition} accepts a state, an input, an output, 133 | and a next state and adds the transition defined by those four values 134 | to a new L{TransitionTable} which it returns. 135 | """ 136 | table = TransitionTable() 137 | more = table.addTransition("foo", "bar", "baz", "quux") 138 | self.assertEqual({"foo": {"bar": Transition("baz", "quux")}}, more.table) 139 | 140 | 141 | def test_addTransitionDoesNotMutate(self): 142 | """ 143 | L{TransitionTable.addTransition} does not change the L{TransitionTable} 144 | it is called on. 145 | """ 146 | table = TransitionTable({"foo": {"bar": Transition("baz", "quux")}}) 147 | table.addTransition("apple", "banana", "clementine", "date") 148 | self.assertEqual({"foo": {"bar": Transition("baz", "quux")}}, table.table) 149 | 150 | 151 | def test_addTransitions(self): 152 | """ 153 | L{TransitionTable.addTransitions} accepts a state and a mapping from 154 | inputs to output, next state pairs and adds all of those transitions to 155 | the given state to a new L{TransitionTable} which it returns. 156 | """ 157 | table = TransitionTable() 158 | more = table.addTransitions( 159 | "apple", { 160 | "banana": ("clementine", "date"), 161 | "eggplant": ("fig", "grape")}) 162 | self.assertEqual( 163 | {"apple": { 164 | "banana": Transition("clementine", "date"), 165 | "eggplant": Transition("fig", "grape")}}, 166 | more.table) 167 | 168 | 169 | def test_addTransitionsDoesNotMutate(self): 170 | """ 171 | L{TransitionTable.addTransitions} does not change the 172 | L{TransitionTable} it is called on. 173 | """ 174 | table = TransitionTable({"foo": {"bar": Transition("baz", "quux")}}) 175 | table.addTransitions("apple", {"banana": ("clementine", "date")}) 176 | self.assertEqual({"foo": {"bar": Transition("baz", "quux")}}, table.table) 177 | 178 | 179 | def test_addTerminalState(self): 180 | """ 181 | L{TransitionTable.addTerminalState} returns a L{TransitionTable} that 182 | includes the given state in its table with no transitions defined. 183 | """ 184 | table = TransitionTable() 185 | more = table.addTerminalState("foo") 186 | self.assertEqual({"foo": {}}, more.table) 187 | 188 | 189 | 190 | class ConstructExceptionTests(TestCase): 191 | """ 192 | Tests for the exceptions that L{constructFiniteStateMachine} raises when 193 | called with bad inputs. 194 | """ 195 | def test_extraTransitionState(self): 196 | """ 197 | L{ExtraTransitionState} is raised if there are any keys in 198 | C{transitions} that are not defined by C{state}. 199 | """ 200 | extra = object() 201 | exc = self.assertRaises( 202 | ExtraTransitionState, 203 | constructFiniteStateMachine, 204 | Input, Output, State, 205 | TransitionTable({State.amber: {}, extra: {}}), 206 | State.amber, [], {}, NULL_WORLD) 207 | self.assertEqual(({extra},), exc.args) 208 | 209 | 210 | def test_missingTransitionState(self): 211 | """ 212 | L{MissingTransitionState} is raised if there are any keys in 213 | C{transitions} that are not defined by C{state}. 214 | """ 215 | exc = self.assertRaises( 216 | MissingTransitionState, 217 | constructFiniteStateMachine, 218 | Input, Output, State, TransitionTable({}), State.amber, [], {}, 219 | NULL_WORLD) 220 | self.assertEqual(({State.amber},), exc.args) 221 | 222 | 223 | def test_extraTransitionInput(self): 224 | """ 225 | L{ExtraTransitionInput} is raised if there are any keys in any of the 226 | values of C{transitions} that are not defined by C{input}. 227 | """ 228 | extra = object() 229 | exc = self.assertRaises( 230 | ExtraTransitionInput, 231 | constructFiniteStateMachine, 232 | Input, Output, State, 233 | TransitionTable({ 234 | State.amber: { 235 | Input.apple: Transition(Output.aardvark, State.amber), 236 | extra: Transition(Output.aardvark, State.amber)}}), 237 | State.amber, [], {}, NULL_WORLD) 238 | self.assertEqual(({extra},), exc.args) 239 | 240 | 241 | def test_missingTransitionInput(self): 242 | """ 243 | L{MissingTransitionInput} is raised if any of the values defined by 244 | C{input} appears in none of the values of C{transitions}. 245 | """ 246 | exc = self.assertRaises( 247 | MissingTransitionInput, 248 | constructFiniteStateMachine, 249 | Input, Output, State, 250 | TransitionTable({State.amber: {}}), State.amber, [], {}, 251 | NULL_WORLD) 252 | self.assertEqual(({Input.apple},), exc.args) 253 | 254 | 255 | def test_extraTransitionOutput(self): 256 | """ 257 | L{ExtraTransitionInput} is raised if there are any output values 258 | defined by C{transitions} that are not defined by C{output}. 259 | """ 260 | extra = object() 261 | exc = self.assertRaises( 262 | ExtraTransitionOutput, 263 | constructFiniteStateMachine, 264 | Input, Output, State, 265 | TransitionTable({ 266 | State.amber: {Input.apple: Transition([extra], None)}}), 267 | State.amber, [], {}, NULL_WORLD) 268 | self.assertEqual(({extra},), exc.args) 269 | 270 | 271 | def test_missingTransitionOutput(self): 272 | """ 273 | L{MissingTransitionOutput} is raised if any of the values defined by 274 | C{output} does not appear as an output value defined by C{transitions}. 275 | """ 276 | exc = self.assertRaises( 277 | MissingTransitionOutput, 278 | constructFiniteStateMachine, 279 | Input, Output, State, 280 | TransitionTable({ 281 | State.amber: {Input.apple: Transition([], None)}}), 282 | State.amber, [], {}, NULL_WORLD) 283 | self.assertEqual(({Output.aardvark},), exc.args) 284 | 285 | 286 | def test_extraTransitionNextState(self): 287 | """ 288 | L{ExtraTransitionNextState} is raised if any of the next state 289 | definitions in C{transitions} is not defined by C{state}. 290 | """ 291 | extra = object() 292 | exc = self.assertRaises( 293 | ExtraTransitionNextState, 294 | constructFiniteStateMachine, 295 | MoreInput, Output, State, 296 | TransitionTable().addTransitions( 297 | State.amber, { 298 | MoreInput.apple: ([Output.aardvark], State.amber), 299 | MoreInput.banana: ([Output.aardvark], extra)}), 300 | State.amber, [], {}, NULL_WORLD) 301 | self.assertEqual(({extra},), exc.args) 302 | 303 | 304 | def test_missingTransitionNextState(self): 305 | """ 306 | L{MissingTransitionNextState} is raised if any of the values defined by 307 | C{state} appears nowhere in C{transitions} as a next state. 308 | """ 309 | transitions = TransitionTable() 310 | transitions = transitions.addTransition( 311 | MoreState.amber, Input.apple, [Output.aardvark], MoreState.amber) 312 | transitions = transitions.addTerminalState(MoreState.blue) 313 | 314 | exc = self.assertRaises( 315 | MissingTransitionNextState, 316 | constructFiniteStateMachine, 317 | Input, Output, MoreState, transitions, 318 | MoreState.amber, [], {}, NULL_WORLD) 319 | self.assertEqual(({MoreState.blue},), exc.args) 320 | 321 | 322 | def test_nextStateNotMissingIfInitial(self): 323 | """ 324 | L{MissingTransitionNextState} is not raised if a value defined by 325 | C{state} appears nowhere in C{transitions} as a next state but is given 326 | as C{initial}. 327 | """ 328 | transitions = TransitionTable() 329 | transitions = transitions.addTransition( 330 | MoreState.amber, Input.apple, [Output.aardvark], MoreState.amber) 331 | transitions = transitions.addTerminalState(MoreState.blue) 332 | 333 | constructFiniteStateMachine( 334 | Input, Output, MoreState, transitions, 335 | MoreState.blue, [], {}, NULL_WORLD) 336 | 337 | 338 | def test_invalidInitialState(self): 339 | """ 340 | L{InvalidInitialState} is raised if the value given for C{initial} is 341 | not defined by C{state}. 342 | """ 343 | extra = object() 344 | transitions = TransitionTable() 345 | transitions = transitions.addTransition( 346 | State.amber, Input.apple, [Output.aardvark], State.amber) 347 | exc = self.assertRaises( 348 | InvalidInitialState, 349 | constructFiniteStateMachine, 350 | Input, Output, State, transitions, 351 | extra, [], {}, NULL_WORLD) 352 | self.assertEqual((extra,), exc.args) 353 | 354 | 355 | def test_extraInputContext(self): 356 | """ 357 | L{ExtraInputContext} is raised if there are keys in C{inputContext} 358 | which are not symbols in the output alphabet. 359 | """ 360 | extra = object() 361 | transitions = TransitionTable() 362 | transitions = transitions.addTransition( 363 | State.amber, Input.apple, [Output.aardvark], State.amber) 364 | exc = self.assertRaises( 365 | ExtraInputContext, 366 | constructFiniteStateMachine, 367 | Input, Output, State, transitions, 368 | State.amber, [], {extra: None}, NULL_WORLD) 369 | self.assertEqual(({extra},), exc.args) 370 | 371 | 372 | def test_richInputInterface(self): 373 | """ 374 | L{DoesNotImplement} is raised if a rich input type is given which does 375 | not implement the interface required by one of the outputs which can be 376 | produced when that input is received. 377 | """ 378 | apple = trivialInput(Input.apple) 379 | transitions = TransitionTable() 380 | transitions = transitions.addTransition( 381 | State.amber, Input.apple, [Output.aardvark], State.amber) 382 | 383 | self.assertRaises( 384 | DoesNotImplement, 385 | constructFiniteStateMachine, 386 | Input, Output, State, transitions, 387 | State.amber, [apple], {Output.aardvark: IRequiredByAardvark}, 388 | NULL_WORLD) 389 | 390 | 391 | 392 | class TrivialInputTests(TestCase): 393 | """ 394 | Tests for L{trivialInput}. 395 | """ 396 | def test_interface(self): 397 | """ 398 | The type returned by L{trivialInput} implements L{IRichInput}. 399 | """ 400 | self.assertTrue(verifyClass(IRichInput, trivialInput(Input.apple))) 401 | 402 | 403 | def test_interfaceOnInstance(self): 404 | """ 405 | The an instance of the object returned by L{trivialInput} provides 406 | L{IRichInput}. 407 | """ 408 | self.assertTrue(verifyObject(IRichInput, trivialInput(Input.apple)())) 409 | 410 | 411 | def test_symbol(self): 412 | """ 413 | The C{symbol} method of the object returned by L{trivialInput} returns 414 | the symbol passed in. 415 | """ 416 | self.assertIs(Input.apple, trivialInput(Input.apple).symbol()) 417 | 418 | 419 | def test_repr(self): 420 | """ 421 | The result of L{repr} when called with an instance of the type returned 422 | by L{trivialInput} is a string that mentions the symbol name and 423 | nothing else. 424 | """ 425 | self.assertEqual("", repr(trivialInput(Input.apple)())) 426 | 427 | 428 | LOG_ANIMAL = MessageType( 429 | u"testing:fsm:animalworld:aardvark", [], 430 | u"An animal! Not really. A log event actually. Just a distinct message " 431 | u"type that can be recognized by tests to verify something was logged.") 432 | 433 | class AnimalWorld(FancyStrMixin, object): 434 | logger = Logger() 435 | 436 | def __init__(self, animals): 437 | """ 438 | @param animals: A L{list} to which output animals will be appended. 439 | """ 440 | self.animals = animals 441 | 442 | 443 | def identifier(self): 444 | """ 445 | Generate a stable, useful identifier for this L{AnimalWorld}. 446 | """ 447 | return u"" 448 | 449 | 450 | def output_AARDVARK(self, context): 451 | self.animals.append((Output.aardvark, context)) 452 | LOG_ANIMAL().write(self.logger) 453 | 454 | 455 | class MethodSuffixOutputerTests(TestCase): 456 | """ 457 | Tests for L{MethodSuffixOutputer}. 458 | """ 459 | def test_wrappedIdentifier(self): 460 | """ 461 | If the wrapped object has an C{identifier} method then its return value 462 | is returned by L{MethodSuffixOutputer.identifier}. 463 | """ 464 | world = AnimalWorld([]) 465 | outputer = MethodSuffixOutputer(world) 466 | self.assertEqual(world.identifier(), outputer.identifier()) 467 | 468 | 469 | def test_fallbackIdentifier(self): 470 | """ 471 | If the wrapped object has no C{identifier} method then 472 | L{MethodSuffixOutputer.identifier} generates an identifier for the 473 | wrapped object and returns that. 474 | """ 475 | world = object() 476 | outputer = MethodSuffixOutputer(world) 477 | self.assertEqual(repr(world), outputer.identifier()) 478 | 479 | 480 | def test_unicodeFallbackIdentifier(self): 481 | """ 482 | If the wrapped object has no C{identifier} method then the identifier 483 | generated by L{MethodSuffixOutputer.identifier} is a L{unicode} string. 484 | """ 485 | world = object() 486 | outputer = MethodSuffixOutputer(world) 487 | self.assertIsInstance(outputer.identifier(), unicode) 488 | 489 | 490 | def test_fallbackIdentifierStable(self): 491 | """ 492 | If L{MethodSuffixOutputer} generates an identifier for the wrapped 493 | object then it generates the same identifier for all calls to 494 | C{identifier} regardless of changes to the wrapped object. 495 | """ 496 | world = ["first state"] 497 | outputer = MethodSuffixOutputer(world) 498 | firstIdentifier = outputer.identifier() 499 | world.append("second state") 500 | secondIdentifier = outputer.identifier() 501 | self.assertEqual(firstIdentifier, secondIdentifier) 502 | 503 | 504 | def test_dispatch(self): 505 | """ 506 | When L{MethodSuffixOutputer.output} is called with an input and the 507 | wrapped object has a method named like I{output_INPUT} where I{INPUT} 508 | is the name of the input given, that method is called with the context 509 | object given. 510 | """ 511 | context = object() 512 | animals = [] 513 | world = AnimalWorld(animals) 514 | outputer = MethodSuffixOutputer(world) 515 | outputer.output(Output.aardvark, context) 516 | self.assertEqual([(Output.aardvark, context)], animals) 517 | 518 | 519 | def test_prefix(self): 520 | """ 521 | If a value is given for the optional second L{MethodSuffixOutputer} 522 | initializer argument then it is used instead of C{"output_"} as the 523 | method dispatch prefix. 524 | """ 525 | animals = [] 526 | 527 | class AlternatePrefixWorld(object): 528 | def foobar_AARDVARK(self, context): 529 | animals.append(context) 530 | 531 | context = object() 532 | world = AlternatePrefixWorld() 533 | outputer = MethodSuffixOutputer(world, "foobar_") 534 | outputer.output(Output.aardvark, context) 535 | self.assertEqual([context], animals) 536 | 537 | 538 | def test_repr(self): 539 | """ 540 | The result of L{MethodSuffixOutputer.__repr__} is a string that 541 | mentions the wrapped object. 542 | """ 543 | world = object() 544 | self.assertEqual( 545 | "" % (world,), 546 | repr(MethodSuffixOutputer(world))) 547 | 548 | 549 | 550 | class IFood(Interface): 551 | radius = Attribute("The radius of the food (all food is spherical)") 552 | 553 | 554 | 555 | @implementer(IFood) 556 | class Gravenstein(trivialInput(Input.apple)): 557 | # Conveniently, apples are approximately spherical. 558 | radius = 3 559 | 560 | 561 | 562 | TRANSITIONS = TransitionTable() 563 | TRANSITIONS = TRANSITIONS.addTransition( 564 | MoreState.amber, Input.apple, [Output.aardvark], MoreState.blue) 565 | TRANSITIONS = TRANSITIONS.addTerminalState(MoreState.blue) 566 | 567 | 568 | class FiniteStateMachineTests(TestCase): 569 | """ 570 | Tests for the L{IFiniteStateMachine} provider returned by 571 | L{constructFiniteStateMachine}. 572 | """ 573 | def setUp(self): 574 | self.animals = [] 575 | self.initial = MoreState.amber 576 | 577 | self.world = AnimalWorld(self.animals) 578 | self.fsm = constructFiniteStateMachine( 579 | Input, Output, MoreState, TRANSITIONS, self.initial, 580 | [Gravenstein], {Output.aardvark: IFood}, 581 | MethodSuffixOutputer(self.world)) 582 | 583 | 584 | def test_interface(self): 585 | """ 586 | L{constructFiniteStateMachine} returns an L{IFiniteStateMachine} provider. 587 | """ 588 | self.assertTrue(verifyObject(IFiniteStateMachine, self.fsm)) 589 | 590 | 591 | def test_initial(self): 592 | """ 593 | L{IFiniteStateMachine.state} is set to the initial state. 594 | """ 595 | self.assertEqual(self.initial, self.fsm.state) 596 | 597 | 598 | def assertOutputLogging(self, logger): 599 | """ 600 | The L{IOutputExecutor} is invoked in the FSM's transition action 601 | context. 602 | """ 603 | loggedTransition = LoggedAction.ofType( 604 | logger.messages, LOG_FSM_TRANSITION)[0] 605 | loggedAnimal = LoggedMessage.ofType(logger.messages, LOG_ANIMAL)[0] 606 | self.assertIn(loggedAnimal, loggedTransition.children) 607 | 608 | 609 | @validateLogging(assertOutputLogging) 610 | def test_outputFromRichInput(self, logger): 611 | """ 612 | L{IFiniteStateMachine.receive} finds the transition for the given rich 613 | input in the machine's current state and returns the corresponding 614 | output. 615 | """ 616 | self.fsm.logger = logger 617 | self.world.logger = logger 618 | self.assertEqual([Output.aardvark], self.fsm.receive(Gravenstein())) 619 | 620 | 621 | @validateLogging(assertOutputLogging) 622 | def test_outputFromSymbolInput(self, logger): 623 | """ 624 | L{IFiniteStateMachine.receive} finds the transition for the symbol 625 | input in the machine's current state and returns the corresponding 626 | output. 627 | """ 628 | self.fsm = constructFiniteStateMachine( 629 | Input, Output, MoreState, TRANSITIONS, self.initial, 630 | [Gravenstein], {}, MethodSuffixOutputer(self.world)) 631 | 632 | self.fsm.logger = logger 633 | self.world.logger = logger 634 | self.assertEqual([Output.aardvark], self.fsm.receive(Input.apple)) 635 | 636 | 637 | def assertTransitionLogging(self, logger, richInput): 638 | """ 639 | State transitions by L{IFiniteStateMachine} are logged. 640 | """ 641 | loggedTransition = LoggedAction.ofType( 642 | logger.messages, LOG_FSM_TRANSITION)[0] 643 | assertContainsFields( 644 | self, loggedTransition.startMessage, 645 | {u"fsm_identifier": u"", 646 | u"fsm_state": u"", 647 | u"fsm_input": u"", 648 | u"fsm_rich_input": richInput 649 | }) 650 | self.assertTrue(loggedTransition.succeeded) 651 | assertContainsFields(self, loggedTransition.endMessage, 652 | {u"fsm_next_state": u"", 653 | u"fsm_output": [u""], 654 | }) 655 | 656 | 657 | @validateLogging(assertTransitionLogging, None) 658 | def test_nextStateGivenSymbolInput(self, logger): 659 | """ 660 | L{IFiniteStateMachine.receive} changes L{IFiniteStateMachine.state} to 661 | the next state defined for the given symbolic input in the machine's 662 | current state. 663 | """ 664 | self.fsm = constructFiniteStateMachine( 665 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 666 | [Gravenstein], {}, MethodSuffixOutputer(AnimalWorld([])), logger) 667 | self.fsm.logger = logger 668 | self.fsm.receive(Input.apple) 669 | self.assertEqual(MoreState.blue, self.fsm.state) 670 | 671 | 672 | @validateLogging(assertTransitionLogging, u"") 673 | def test_nextStateGivenRichInput(self, logger): 674 | """ 675 | L{IFiniteStateMachine.receive} changes L{IFiniteStateMachine.state} to 676 | the next state defined for the given rich input in the machine's 677 | current state. 678 | """ 679 | self.fsm.logger = logger 680 | self.fsm.receive(Gravenstein()) 681 | self.assertEqual(MoreState.blue, self.fsm.state) 682 | 683 | 684 | def test_unhandledInput(self): 685 | """ 686 | L{IFiniteStateMachine.receive} raises L{UnhandledInput} if called with 687 | an input that isn't handled in the machine's current state. 688 | """ 689 | self.fsm.receive(Gravenstein()) 690 | exc = self.assertRaises(UnhandledInput, self.fsm.receive, 691 | Gravenstein()) 692 | self.assertEqual((MoreState.blue, Input.apple), exc.args) 693 | 694 | 695 | def test_illegalRichInput(self): 696 | """ 697 | L{IFiniteStateMachine.receive} raises L{IllegalInput} if called with a 698 | rich input that doesn't map to a symbol in the input alphabet. 699 | """ 700 | banana = trivialInput(MoreInput.banana) 701 | exc = self.assertRaises(IllegalInput, self.fsm.receive, banana()) 702 | self.assertEqual((MoreInput.banana,), exc.args) 703 | 704 | 705 | def test_illegalInput(self): 706 | """ 707 | L{IFiniteStateMachine.receive} raises L{IllegalInput} if called with 708 | an input that isn't in the input alphabet. 709 | """ 710 | exc = self.assertRaises(IllegalInput, self.fsm.receive, "not symbol") 711 | self.assertEqual(("not symbol",), exc.args) 712 | 713 | 714 | def test_inputContext(self): 715 | """ 716 | The context passed to L{IOutputExecutor.output} is the input. 717 | """ 718 | apple = Gravenstein() 719 | self.fsm.receive(apple) 720 | self.assertEqual([(Output.aardvark, apple)], self.animals) 721 | 722 | 723 | def test_FiniteStateInterpreterRepr(self): 724 | """ 725 | The result of L{_FiniteStateInterpreter.__repr__} is a string that 726 | includes the L{IOutputExecutor} provider that 727 | L{_FiniteStateInterpreter} can drive. 728 | """ 729 | fsm = constructFiniteStateMachine( 730 | Input, Output, MoreState, TRANSITIONS, self.initial, 731 | [Gravenstein], {Output.aardvark: IFood}, 732 | MethodSuffixOutputer(self.world), None) 733 | self.assertEqual( 734 | repr(fsm), 735 | "" % (MethodSuffixOutputer(self.world),)) 736 | 737 | 738 | 739 | class IsTerminalTests(TestCase): 740 | """ 741 | Tests for L{_FiniteStateMachine._isTerminal}. 742 | """ 743 | def test_empty(self): 744 | """ 745 | L{_FiniteStateMachine._isTerminal} returns C{True} if a state that 746 | defines handling of no input symbols. 747 | """ 748 | fsm = constructFiniteStateMachine( 749 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 750 | [Gravenstein], {Output.aardvark: IFood}, 751 | MethodSuffixOutputer(AnimalWorld([]))) 752 | self.assertTrue(fsm._isTerminal(MoreState.blue)) 753 | 754 | 755 | def test_selfTransition(self): 756 | """ 757 | L{_FiniteStateMachine._isTerminal} returns C{True} if a state defines 758 | handling of inputs that generate no outputs and do not change the state 759 | of the machine. 760 | """ 761 | transitions = TRANSITIONS.addTransition( 762 | MoreState.blue, Input.apple, [], MoreState.blue) 763 | fsm = constructFiniteStateMachine( 764 | Input, Output, MoreState, transitions, MoreState.amber, 765 | [Gravenstein], {Output.aardvark: IFood}, 766 | MethodSuffixOutputer(AnimalWorld([]))) 767 | self.assertTrue(fsm._isTerminal(MoreState.blue)) 768 | 769 | 770 | def test_output(self): 771 | """ 772 | L{_FiniteStateMachine._isTerminal} returns C{False} if a state defines 773 | handling of inputs that generate any output. 774 | """ 775 | transitions = TRANSITIONS.addTransition( 776 | MoreState.blue, Input.apple, [Output.aardvark], MoreState.blue) 777 | fsm = constructFiniteStateMachine( 778 | Input, Output, MoreState, transitions, MoreState.amber, 779 | [Gravenstein], {Output.aardvark: IFood}, 780 | MethodSuffixOutputer(AnimalWorld([]))) 781 | self.assertFalse(fsm._isTerminal(MoreState.blue)) 782 | 783 | 784 | def test_stateChange(self): 785 | """ 786 | L{_FiniteStateMachine._isTerminal} returns C{False} if a state defines 787 | handling of inputs that cause a state change. 788 | """ 789 | transitions = TRANSITIONS.addTransition( 790 | MoreState.blue, Input.apple, [], MoreState.amber) 791 | fsm = constructFiniteStateMachine( 792 | Input, Output, MoreState, transitions, MoreState.amber, 793 | [Gravenstein], {Output.aardvark: IFood}, 794 | MethodSuffixOutputer(AnimalWorld([]))) 795 | self.assertFalse(fsm._isTerminal(MoreState.blue)) 796 | 797 | 798 | 799 | class FiniteStateMachineLoggingTests(TestCase): 800 | """ 801 | Tests for logging behavior of the L{IFiniteStateMachine} returned by 802 | L{constructFiniteStateMachine}. 803 | """ 804 | if logSkipReason is not None: 805 | skip = logSkipReason 806 | 807 | def test_logger(self): 808 | """ 809 | L{constructFiniteStateMachine} returns a FSM that also has a C{logger} 810 | attribute that is an L{eliot.Logger} instance. 811 | """ 812 | fsm = constructFiniteStateMachine( 813 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 814 | [Gravenstein], {Output.aardvark: IFood}, 815 | MethodSuffixOutputer(AnimalWorld([]))) 816 | self.assertIsInstance(fsm.logger, Logger) 817 | 818 | 819 | @validateLogging(None) 820 | def test_loggerOverride(self, logger): 821 | """ 822 | If an argument is given for the C{logger} argument to 823 | L{constructFiniteStateMachine} then that object is used as the logger 824 | of the resulting finite state machine. 825 | """ 826 | fsm = constructFiniteStateMachine( 827 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 828 | [Gravenstein], {Output.aardvark: IFood}, 829 | MethodSuffixOutputer(AnimalWorld([])), logger) 830 | self.assertIs(logger, fsm.logger) 831 | 832 | 833 | @validateLogging(None) 834 | def test_initializationLogging(self, logger): 835 | """ 836 | The initialization of the L{IFiniteStateMachine} created by 837 | L{constructFiniteStateMachine} is logged. 838 | """ 839 | constructFiniteStateMachine( 840 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 841 | [Gravenstein], {Output.aardvark: IFood}, 842 | MethodSuffixOutputer(AnimalWorld([])), logger) 843 | self.assertTrue( 844 | issuperset(logger.messages[0], { 845 | u"fsm_identifier": u"", 846 | u"fsm_state": u"", 847 | u"action_status": u"started", 848 | u"action_type": u"fsm:initialize", 849 | })) 850 | 851 | 852 | @validateLogging(None) 853 | def test_terminalLogging(self, logger): 854 | """ 855 | When the L{IFiniteStateMachine} enters a terminal state the 856 | initialization action is finished successfully. 857 | """ 858 | fsm = constructFiniteStateMachine( 859 | Input, Output, MoreState, TRANSITIONS, MoreState.amber, 860 | [Gravenstein], {Output.aardvark: IFood}, 861 | MethodSuffixOutputer(AnimalWorld([])), logger) 862 | 863 | fsm.receive(Gravenstein()) 864 | 865 | (initialize,) = LoggedAction.of_type( 866 | logger.messages, LOG_FSM_INITIALIZE 867 | ) 868 | 869 | assertContainsFields( 870 | self, initialize.end_message, { 871 | u"fsm_terminal_state": u"", 872 | u"action_status": u"succeeded", 873 | } 874 | ) 875 | 876 | @validateLogging(None) 877 | def test_noRepeatedTerminalLogging(self, logger): 878 | """ 879 | When the L{IFiniteStateMachine} receives an input in a terminal state 880 | (and does not generate an output or change to a different state, as is 881 | required in terminal states) it does not re-log the completion of its 882 | initialization event. 883 | """ 884 | # Accept this input in MoreState.blue but remain a terminal state (no 885 | # output, no state change). 886 | transitions = TRANSITIONS.addTransition( 887 | MoreState.blue, Input.apple, [], MoreState.blue) 888 | 889 | fsm = constructFiniteStateMachine( 890 | Input, Output, MoreState, transitions, MoreState.amber, 891 | [Gravenstein], {Output.aardvark: IFood}, 892 | MethodSuffixOutputer(AnimalWorld([])), logger) 893 | 894 | fsm.receive(Gravenstein()) 895 | howMany = len(logger.messages) 896 | 897 | fsm.receive(Gravenstein()) 898 | 899 | # No additional initialization messages please! 900 | self.assertEqual([], [ 901 | msg for msg in logger.messages[howMany:] 902 | if msg[u"action_type"] == u"fsm:initialize"]) 903 | 904 | 905 | 906 | class Restricted(object): 907 | foo = "a" 908 | attribute = stateful(lambda r: r.foo, "a") 909 | 910 | 911 | 912 | class StatefulTests(TestCase): 913 | """ 914 | Tests for L{stateful}. 915 | """ 916 | def test_allowedSet(self): 917 | """ 918 | In an allowed state, the value of the descriptor can be get and set 919 | using normal attribute access. 920 | """ 921 | value = object() 922 | obj = Restricted() 923 | obj.attribute = value 924 | self.assertIs(value, obj.attribute) 925 | 926 | 927 | def test_allowedDelete(self): 928 | """ 929 | In an allowed state, the value of the descriptor can be deleted 930 | using normal attribute deletion. 931 | """ 932 | value = object() 933 | obj = Restricted() 934 | obj.attribute = value 935 | del obj.attribute 936 | self.assertRaises(AttributeError, getattr, obj, "attribute") 937 | 938 | 939 | def test_allowedDeleteMissing(self): 940 | """ 941 | In an allowed state, if the descriptor has no value, an attempt to 942 | delete it raises L{AttributeError}. 943 | """ 944 | obj = Restricted() 945 | self.assertRaises(AttributeError, delattr, obj, "attribute") 946 | 947 | 948 | def test_disallowedGet(self): 949 | """ 950 | Out of the allowed states, L{WrongState} is raised by an attempt to get 951 | the value of the descriptor. 952 | """ 953 | obj = Restricted() 954 | obj.foo = "b" 955 | self.assertRaises(WrongState, getattr, obj, "attribute") 956 | 957 | 958 | def test_disallowedSet(self): 959 | """ 960 | Out of the allowed states, L{WrongState} is raised by an attempt to 961 | set the value of the descriptor. 962 | """ 963 | obj = Restricted() 964 | obj.foo = "b" 965 | self.assertRaises(WrongState, setattr, obj, "attribute", object()) 966 | 967 | 968 | def test_disallowedDelete(self): 969 | """ 970 | Out of the allowed states, L{WrongState} is raised by an attempt to 971 | delete the value of the descriptor. 972 | """ 973 | obj = Restricted() 974 | obj.foo = "b" 975 | self.assertRaises(WrongState, delattr, obj, "attribute") 976 | 977 | 978 | def test_typeAccess(self): 979 | """ 980 | Getting the descriptor attribute from the type it is defined on returns 981 | the L{stateful} instance itself. 982 | """ 983 | self.assertIs(Restricted.__dict__["attribute"], Restricted.attribute) 984 | -------------------------------------------------------------------------------- /machinist/topfiles/10.feature: -------------------------------------------------------------------------------- 1 | `machinist.MethodSuffixOutputter` now optionally accepts an initializer argument to determine what method prefix it will use. 2 | -------------------------------------------------------------------------------- /machinist/topfiles/14.bugfix: -------------------------------------------------------------------------------- 1 | trivialInput used to produce a type which provided IRichInput, but an instance of said type would not provide IRichInput. This has been fixed and now trivialInput produces a type that implements IRichInput, whose instances provide IRichInput. 2 | -------------------------------------------------------------------------------- /machinist/topfiles/20.misc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScatterHQ/machinist/1d1c017ac03be8e737d50af0dfabf31722ddc621/machinist/topfiles/20.misc -------------------------------------------------------------------------------- /machinist/topfiles/5.bugfix: -------------------------------------------------------------------------------- 1 | machinist now supports and requires eliot 0.4.0 or newer. 2 | -------------------------------------------------------------------------------- /machinist/topfiles/6.feature: -------------------------------------------------------------------------------- 1 | machinist's eliot dependency is now optional. If eliot is available then transitions will be logged. If it is not then they will not be. 2 | -------------------------------------------------------------------------------- /machinist/topfiles/7.feature: -------------------------------------------------------------------------------- 1 | The finite state machine produced by constructFiniteStateMachine previously could only receive providers of IRichInput. This changes the `receive` function to be able to also receive symbol inputs. 2 | -------------------------------------------------------------------------------- /machinist/topfiles/9.doc: -------------------------------------------------------------------------------- 1 | The turnstile example now uses the correct signature for its `output_` methods. 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright Hybrid Logic Ltd. See LICENSE file for details. 3 | 4 | from inspect import cleandoc 5 | 6 | from setuptools import setup 7 | 8 | import versioneer 9 | versioneer.VCS = 'git' 10 | versioneer.versionfile_source = 'machinist/_version.py' 11 | versioneer.versionfile_build = 'machinist/_version.py' 12 | versioneer.tag_prefix = 'machinist-' # tags are like 1.2.0 13 | versioneer.parentdir_prefix = 'machinist-' # dirname like 'machinist-1.2.0' 14 | 15 | 16 | # For the convenience of the travis configuration, make this information 17 | # particularly easy to find. See .travis.yml. 18 | _MINIMUM_ELIOT_VERSION = "0.5.0" 19 | 20 | if __name__ == '__main__': 21 | setup( 22 | name="machinist", 23 | version=versioneer.get_version(), 24 | # Allow versioneer to integrate with setup commands: 25 | cmdclass=versioneer.get_cmdclass(), 26 | packages=["machinist", "machinist.test"], 27 | description=cleandoc(""" 28 | Machinist is a tool for building finite state machines. 29 | """), 30 | long_description=cleandoc(""" 31 | A finite state machine maps simple, symbolic inputs to simple, symbolic 32 | outputs. In this context, symbolic means that nothing differentiates 33 | the values from each other apart from their identity. 34 | 35 | The mapping from inputs to outputs also includes definitions for state 36 | transitions. The current state of the machine changes to a new value 37 | each time an input is mapped to an output (though the new value may be 38 | the same as the old value). 39 | """), 40 | url="https://github.com/ClusterHQ/machinist", 41 | classifiers=[ 42 | "Development Status :: 4 - Beta", 43 | "Intended Audience :: Developers", 44 | "License :: OSI Approved :: Apache Software License", 45 | "Operating System :: MacOS :: MacOS X", 46 | "Operating System :: Microsoft :: Windows", 47 | "Operating System :: POSIX", 48 | 49 | # General classifiers to indicate "this project supports Python 2" and 50 | # "this project supports Python 3". 51 | "Programming Language :: Python :: 2", 52 | 53 | # More specific classifiers to indicate more precisely which versions 54 | # of those languages the project supports. 55 | "Programming Language :: Python :: 2.7", 56 | 57 | "Programming Language :: Python :: Implementation :: CPython", 58 | "Programming Language :: Python :: Implementation :: PyPy", 59 | 60 | "Topic :: Software Development :: Libraries :: Python Modules", 61 | ], 62 | install_requires=[ 63 | "zope.interface>=3.6.0", "twisted>=13.1", 64 | ], 65 | extras_require={ 66 | "dev": ["sphinx>=1.2.2"], 67 | "logging": ["eliot>=" + _MINIMUM_ELIOT_VERSION], 68 | }, 69 | test_suite="machinist", 70 | ) 71 | -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | 2 | # Version: 0.10 3 | 4 | """ 5 | The Versioneer 6 | ============== 7 | 8 | * like a rocketeer, but for versions! 9 | * https://github.com/warner/python-versioneer 10 | * Brian Warner 11 | * License: Public Domain 12 | * Compatible With: python2.6, 2.7, and 3.2, 3.3 13 | 14 | [![Build Status](https://travis-ci.org/warner/python-versioneer.png?branch=master)](https://travis-ci.org/warner/python-versioneer) 15 | 16 | This is a tool for managing a recorded version number in distutils-based 17 | python projects. The goal is to remove the tedious and error-prone "update 18 | the embedded version string" step from your release process. Making a new 19 | release should be as easy as recording a new tag in your version-control 20 | system, and maybe making new tarballs. 21 | 22 | 23 | ## Quick Install 24 | 25 | * `pip install versioneer` to somewhere to your $PATH 26 | * run `versioneer-installer` in your source tree: this installs `versioneer.py` 27 | * follow the instructions below (also in the `versioneer.py` docstring) 28 | 29 | ## Version Identifiers 30 | 31 | Source trees come from a variety of places: 32 | 33 | * a version-control system checkout (mostly used by developers) 34 | * a nightly tarball, produced by build automation 35 | * a snapshot tarball, produced by a web-based VCS browser, like github's 36 | "tarball from tag" feature 37 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 38 | 39 | Within each source tree, the version identifier (either a string or a number, 40 | this tool is format-agnostic) can come from a variety of places: 41 | 42 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 43 | about recent "tags" and an absolute revision-id 44 | * the name of the directory into which the tarball was unpacked 45 | * an expanded VCS variable ($Id$, etc) 46 | * a `_version.py` created by some earlier build step 47 | 48 | For released software, the version identifier is closely related to a VCS 49 | tag. Some projects use tag names that include more than just the version 50 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 51 | needs to strip the tag prefix to extract the version identifier. For 52 | unreleased software (between tags), the version identifier should provide 53 | enough information to help developers recreate the same tree, while also 54 | giving them an idea of roughly how old the tree is (after version 1.2, before 55 | version 1.3). Many VCS systems can report a description that captures this, 56 | for example 'git describe --tags --dirty --always' reports things like 57 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 58 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 59 | uncommitted changes. 60 | 61 | The version identifier is used for multiple purposes: 62 | 63 | * to allow the module to self-identify its version: `myproject.__version__` 64 | * to choose a name and prefix for a 'setup.py sdist' tarball 65 | 66 | ## Theory of Operation 67 | 68 | Versioneer works by adding a special `_version.py` file into your source 69 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 70 | dynamically ask the VCS tool for version information at import time. However, 71 | when you use "setup.py build" or "setup.py sdist", `_version.py` in the new 72 | copy is replaced by a small static file that contains just the generated 73 | version data. 74 | 75 | `_version.py` also contains `$Revision$` markers, and the installation 76 | process marks `_version.py` to have this marker rewritten with a tag name 77 | during the "git archive" command. As a result, generated tarballs will 78 | contain enough information to get the proper version. 79 | 80 | 81 | ## Installation 82 | 83 | First, decide on values for the following configuration variables: 84 | 85 | * `versionfile_source`: 86 | 87 | A project-relative pathname into which the generated version strings should 88 | be written. This is usually a `_version.py` next to your project's main 89 | `__init__.py` file. If your project uses `src/myproject/__init__.py`, this 90 | should be `src/myproject/_version.py`. This file should be checked in to 91 | your VCS as usual: the copy created below by `setup.py versioneer` will 92 | include code that parses expanded VCS keywords in generated tarballs. The 93 | 'build' and 'sdist' commands will replace it with a copy that has just the 94 | calculated version string. 95 | 96 | * `versionfile_build`: 97 | 98 | Like `versionfile_source`, but relative to the build directory instead of 99 | the source directory. These will differ when your setup.py uses 100 | 'package_dir='. If you have `package_dir={'myproject': 'src/myproject'}`, 101 | then you will probably have `versionfile_build='myproject/_version.py'` and 102 | `versionfile_source='src/myproject/_version.py'`. 103 | 104 | * `tag_prefix`: 105 | 106 | a string, like 'PROJECTNAME-', which appears at the start of all VCS tags. 107 | If your tags look like 'myproject-1.2.0', then you should use 108 | tag_prefix='myproject-'. If you use unprefixed tags like '1.2.0', this 109 | should be an empty string. 110 | 111 | * `parentdir_prefix`: 112 | 113 | a string, frequently the same as tag_prefix, which appears at the start of 114 | all unpacked tarball filenames. If your tarball unpacks into 115 | 'myproject-1.2.0', this should be 'myproject-'. 116 | 117 | This tool provides one script, named `versioneer-installer`. That script does 118 | one thing: write a copy of `versioneer.py` into the current directory. 119 | 120 | To versioneer-enable your project: 121 | 122 | * 1: Run `versioneer-installer` to copy `versioneer.py` into the top of your 123 | source tree. 124 | 125 | * 2: add the following lines to the top of your `setup.py`, with the 126 | configuration values you decided earlier: 127 | 128 | import versioneer 129 | versioneer.versionfile_source = 'src/myproject/_version.py' 130 | versioneer.versionfile_build = 'myproject/_version.py' 131 | versioneer.tag_prefix = '' # tags are like 1.2.0 132 | versioneer.parentdir_prefix = 'myproject-' # dirname like 'myproject-1.2.0' 133 | 134 | * 3: add the following arguments to the setup() call in your setup.py: 135 | 136 | version=versioneer.get_version(), 137 | cmdclass=versioneer.get_cmdclass(), 138 | 139 | * 4: now run `setup.py versioneer`, which will create `_version.py`, and 140 | will modify your `__init__.py` to define `__version__` (by calling a 141 | function from `_version.py`). It will also modify your `MANIFEST.in` to 142 | include both `versioneer.py` and the generated `_version.py` in sdist 143 | tarballs. 144 | 145 | * 5: commit these changes to your VCS. To make sure you won't forget, 146 | `setup.py versioneer` will mark everything it touched for addition. 147 | 148 | ## Post-Installation Usage 149 | 150 | Once established, all uses of your tree from a VCS checkout should get the 151 | current version string. All generated tarballs should include an embedded 152 | version string (so users who unpack them will not need a VCS tool installed). 153 | 154 | If you distribute your project through PyPI, then the release process should 155 | boil down to two steps: 156 | 157 | * 1: git tag 1.0 158 | * 2: python setup.py register sdist upload 159 | 160 | If you distribute it through github (i.e. users use github to generate 161 | tarballs with `git archive`), the process is: 162 | 163 | * 1: git tag 1.0 164 | * 2: git push; git push --tags 165 | 166 | Currently, all version strings must be based upon a tag. Versioneer will 167 | report "unknown" until your tree has at least one tag in its history. This 168 | restriction will be fixed eventually (see issue #12). 169 | 170 | ## Version-String Flavors 171 | 172 | Code which uses Versioneer can learn about its version string at runtime by 173 | importing `_version` from your main `__init__.py` file and running the 174 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 175 | import the top-level `versioneer.py` and run `get_versions()`. 176 | 177 | Both functions return a dictionary with different keys for different flavors 178 | of the version string: 179 | 180 | * `['version']`: condensed tag+distance+shortid+dirty identifier. For git, 181 | this uses the output of `git describe --tags --dirty --always` but strips 182 | the tag_prefix. For example "0.11-2-g1076c97-dirty" indicates that the tree 183 | is like the "1076c97" commit but has uncommitted changes ("-dirty"), and 184 | that this commit is two revisions ("-2-") beyond the "0.11" tag. For 185 | released software (exactly equal to a known tag), the identifier will only 186 | contain the stripped tag, e.g. "0.11". 187 | 188 | * `['full']`: detailed revision identifier. For Git, this is the full SHA1 189 | commit id, followed by "-dirty" if the tree contains uncommitted changes, 190 | e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac-dirty". 191 | 192 | Some variants are more useful than others. Including `full` in a bug report 193 | should allow developers to reconstruct the exact code being tested (or 194 | indicate the presence of local changes that should be shared with the 195 | developers). `version` is suitable for display in an "about" box or a CLI 196 | `--version` output: it can be easily compared against release notes and lists 197 | of bugs fixed in various releases. 198 | 199 | In the future, this will also include a 200 | [PEP-0440](http://legacy.python.org/dev/peps/pep-0440/) -compatible flavor 201 | (e.g. `1.2.post0.dev123`). This loses a lot of information (and has no room 202 | for a hash-based revision id), but is safe to use in a `setup.py` 203 | "`version=`" argument. It also enables tools like *pip* to compare version 204 | strings and evaluate compatibility constraint declarations. 205 | 206 | The `setup.py versioneer` command adds the following text to your 207 | `__init__.py` to place a basic version in `YOURPROJECT.__version__`: 208 | 209 | from ._version import get_versions 210 | __version = get_versions()['version'] 211 | del get_versions 212 | 213 | ## Updating Versioneer 214 | 215 | To upgrade your project to a new release of Versioneer, do the following: 216 | 217 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 218 | * re-run `versioneer-installer` in your source tree to replace `versioneer.py` 219 | * edit `setup.py`, if necessary, to include any new configuration settings indicated by the release notes 220 | * re-run `setup.py versioneer` to replace `SRC/_version.py` 221 | * commit any changed files 222 | 223 | ## Future Directions 224 | 225 | This tool is designed to make it easily extended to other version-control 226 | systems: all VCS-specific components are in separate directories like 227 | src/git/ . The top-level `versioneer.py` script is assembled from these 228 | components by running make-versioneer.py . In the future, make-versioneer.py 229 | will take a VCS name as an argument, and will construct a version of 230 | `versioneer.py` that is specific to the given VCS. It might also take the 231 | configuration arguments that are currently provided manually during 232 | installation by editing setup.py . Alternatively, it might go the other 233 | direction and include code from all supported VCS systems, reducing the 234 | number of intermediate scripts. 235 | 236 | 237 | ## License 238 | 239 | To make Versioneer easier to embed, all its code is hereby released into the 240 | public domain. The `_version.py` that it creates is also in the public 241 | domain. 242 | 243 | """ 244 | 245 | import os, sys, re 246 | from distutils.core import Command 247 | from distutils.command.sdist import sdist as _sdist 248 | from distutils.command.build import build as _build 249 | 250 | versionfile_source = None 251 | versionfile_build = None 252 | tag_prefix = None 253 | parentdir_prefix = None 254 | 255 | VCS = "git" 256 | 257 | 258 | LONG_VERSION_PY = ''' 259 | # This file helps to compute a version number in source trees obtained from 260 | # git-archive tarball (such as those provided by githubs download-from-tag 261 | # feature). Distribution tarballs (build by setup.py sdist) and build 262 | # directories (produced by setup.py build) will contain a much shorter file 263 | # that just contains the computed version number. 264 | 265 | # This file is released into the public domain. Generated by 266 | # versioneer-0.10 (https://github.com/warner/python-versioneer) 267 | 268 | # these strings will be replaced by git during git-archive 269 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 270 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 271 | 272 | 273 | import subprocess 274 | import sys 275 | import errno 276 | 277 | 278 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 279 | assert isinstance(commands, list) 280 | p = None 281 | for c in commands: 282 | try: 283 | # remember shell=False, so use git.cmd on windows, not just git 284 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 285 | stderr=(subprocess.PIPE if hide_stderr 286 | else None)) 287 | break 288 | except EnvironmentError: 289 | e = sys.exc_info()[1] 290 | if e.errno == errno.ENOENT: 291 | continue 292 | if verbose: 293 | print("unable to run %%s" %% args[0]) 294 | print(e) 295 | return None 296 | else: 297 | if verbose: 298 | print("unable to find command, tried %%s" %% (commands,)) 299 | return None 300 | stdout = p.communicate()[0].strip() 301 | if sys.version >= '3': 302 | stdout = stdout.decode() 303 | if p.returncode != 0: 304 | if verbose: 305 | print("unable to run %%s (error)" %% args[0]) 306 | return None 307 | return stdout 308 | 309 | 310 | import sys 311 | import re 312 | import os.path 313 | 314 | def get_expanded_variables(versionfile_abs): 315 | # the code embedded in _version.py can just fetch the value of these 316 | # variables. When used from setup.py, we don't want to import 317 | # _version.py, so we do it with a regexp instead. This function is not 318 | # used from _version.py. 319 | variables = {} 320 | try: 321 | f = open(versionfile_abs,"r") 322 | for line in f.readlines(): 323 | if line.strip().startswith("git_refnames ="): 324 | mo = re.search(r'=\s*"(.*)"', line) 325 | if mo: 326 | variables["refnames"] = mo.group(1) 327 | if line.strip().startswith("git_full ="): 328 | mo = re.search(r'=\s*"(.*)"', line) 329 | if mo: 330 | variables["full"] = mo.group(1) 331 | f.close() 332 | except EnvironmentError: 333 | pass 334 | return variables 335 | 336 | def versions_from_expanded_variables(variables, tag_prefix, verbose=False): 337 | refnames = variables["refnames"].strip() 338 | if refnames.startswith("$Format"): 339 | if verbose: 340 | print("variables are unexpanded, not using") 341 | return {} # unexpanded, so not in an unpacked git-archive tarball 342 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 343 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 344 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 345 | TAG = "tag: " 346 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 347 | if not tags: 348 | # Either we're using git < 1.8.3, or there really are no tags. We use 349 | # a heuristic: assume all version tags have a digit. The old git %%d 350 | # expansion behaves like git log --decorate=short and strips out the 351 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 352 | # between branches and tags. By ignoring refnames without digits, we 353 | # filter out many common branch names like "release" and 354 | # "stabilization", as well as "HEAD" and "master". 355 | tags = set([r for r in refs if re.search(r'\d', r)]) 356 | if verbose: 357 | print("discarding '%%s', no digits" %% ",".join(refs-tags)) 358 | if verbose: 359 | print("likely tags: %%s" %% ",".join(sorted(tags))) 360 | for ref in sorted(tags): 361 | # sorting will prefer e.g. "2.0" over "2.0rc1" 362 | if ref.startswith(tag_prefix): 363 | r = ref[len(tag_prefix):] 364 | if verbose: 365 | print("picking %%s" %% r) 366 | return { "version": r, 367 | "full": variables["full"].strip() } 368 | # no suitable tags, so we use the full revision id 369 | if verbose: 370 | print("no suitable tags, using full revision id") 371 | return { "version": variables["full"].strip(), 372 | "full": variables["full"].strip() } 373 | 374 | def versions_from_vcs(tag_prefix, root, verbose=False): 375 | # this runs 'git' from the root of the source tree. This only gets called 376 | # if the git-archive 'subst' variables were *not* expanded, and 377 | # _version.py hasn't already been rewritten with a short version string, 378 | # meaning we're inside a checked out source tree. 379 | 380 | if not os.path.exists(os.path.join(root, ".git")): 381 | if verbose: 382 | print("no .git in %%s" %% root) 383 | return {} 384 | 385 | GITS = ["git"] 386 | if sys.platform == "win32": 387 | GITS = ["git.cmd", "git.exe"] 388 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 389 | cwd=root) 390 | if stdout is None: 391 | return {} 392 | if not stdout.startswith(tag_prefix): 393 | if verbose: 394 | print("tag '%%s' doesn't start with prefix '%%s'" %% (stdout, tag_prefix)) 395 | return {} 396 | tag = stdout[len(tag_prefix):] 397 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 398 | if stdout is None: 399 | return {} 400 | full = stdout.strip() 401 | if tag.endswith("-dirty"): 402 | full += "-dirty" 403 | return {"version": tag, "full": full} 404 | 405 | 406 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 407 | # Source tarballs conventionally unpack into a directory that includes 408 | # both the project name and a version string. 409 | dirname = os.path.basename(root) 410 | if not dirname.startswith(parentdir_prefix): 411 | if verbose: 412 | print("guessing rootdir is '%%s', but '%%s' doesn't start with prefix '%%s'" %% 413 | (root, dirname, parentdir_prefix)) 414 | return None 415 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 416 | 417 | tag_prefix = "%(TAG_PREFIX)s" 418 | parentdir_prefix = "%(PARENTDIR_PREFIX)s" 419 | versionfile_source = "%(VERSIONFILE_SOURCE)s" 420 | 421 | def get_versions(default={"version": "unknown", "full": ""}, verbose=False): 422 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 423 | # __file__, we can work backwards from there to the root. Some 424 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 425 | # case we can only use expanded variables. 426 | 427 | variables = { "refnames": git_refnames, "full": git_full } 428 | ver = versions_from_expanded_variables(variables, tag_prefix, verbose) 429 | if ver: 430 | return ver 431 | 432 | try: 433 | root = os.path.abspath(__file__) 434 | # versionfile_source is the relative path from the top of the source 435 | # tree (where the .git directory might live) to this file. Invert 436 | # this to find the root from __file__. 437 | for i in range(len(versionfile_source.split("/"))): 438 | root = os.path.dirname(root) 439 | except NameError: 440 | return default 441 | 442 | return (versions_from_vcs(tag_prefix, root, verbose) 443 | or versions_from_parentdir(parentdir_prefix, root, verbose) 444 | or default) 445 | 446 | ''' 447 | 448 | 449 | import subprocess 450 | import sys 451 | import errno 452 | 453 | 454 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 455 | assert isinstance(commands, list) 456 | p = None 457 | for c in commands: 458 | try: 459 | # remember shell=False, so use git.cmd on windows, not just git 460 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 461 | stderr=(subprocess.PIPE if hide_stderr 462 | else None)) 463 | break 464 | except EnvironmentError: 465 | e = sys.exc_info()[1] 466 | if e.errno == errno.ENOENT: 467 | continue 468 | if verbose: 469 | print("unable to run %s" % args[0]) 470 | print(e) 471 | return None 472 | else: 473 | if verbose: 474 | print("unable to find command, tried %s" % (commands,)) 475 | return None 476 | stdout = p.communicate()[0].strip() 477 | if sys.version >= '3': 478 | stdout = stdout.decode() 479 | if p.returncode != 0: 480 | if verbose: 481 | print("unable to run %s (error)" % args[0]) 482 | return None 483 | return stdout 484 | 485 | 486 | import sys 487 | import re 488 | import os.path 489 | 490 | def get_expanded_variables(versionfile_abs): 491 | # the code embedded in _version.py can just fetch the value of these 492 | # variables. When used from setup.py, we don't want to import 493 | # _version.py, so we do it with a regexp instead. This function is not 494 | # used from _version.py. 495 | variables = {} 496 | try: 497 | f = open(versionfile_abs,"r") 498 | for line in f.readlines(): 499 | if line.strip().startswith("git_refnames ="): 500 | mo = re.search(r'=\s*"(.*)"', line) 501 | if mo: 502 | variables["refnames"] = mo.group(1) 503 | if line.strip().startswith("git_full ="): 504 | mo = re.search(r'=\s*"(.*)"', line) 505 | if mo: 506 | variables["full"] = mo.group(1) 507 | f.close() 508 | except EnvironmentError: 509 | pass 510 | return variables 511 | 512 | def versions_from_expanded_variables(variables, tag_prefix, verbose=False): 513 | refnames = variables["refnames"].strip() 514 | if refnames.startswith("$Format"): 515 | if verbose: 516 | print("variables are unexpanded, not using") 517 | return {} # unexpanded, so not in an unpacked git-archive tarball 518 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 519 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 520 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 521 | TAG = "tag: " 522 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 523 | if not tags: 524 | # Either we're using git < 1.8.3, or there really are no tags. We use 525 | # a heuristic: assume all version tags have a digit. The old git %d 526 | # expansion behaves like git log --decorate=short and strips out the 527 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 528 | # between branches and tags. By ignoring refnames without digits, we 529 | # filter out many common branch names like "release" and 530 | # "stabilization", as well as "HEAD" and "master". 531 | tags = set([r for r in refs if re.search(r'\d', r)]) 532 | if verbose: 533 | print("discarding '%s', no digits" % ",".join(refs-tags)) 534 | if verbose: 535 | print("likely tags: %s" % ",".join(sorted(tags))) 536 | for ref in sorted(tags): 537 | # sorting will prefer e.g. "2.0" over "2.0rc1" 538 | if ref.startswith(tag_prefix): 539 | r = ref[len(tag_prefix):] 540 | if verbose: 541 | print("picking %s" % r) 542 | return { "version": r, 543 | "full": variables["full"].strip() } 544 | # no suitable tags, so we use the full revision id 545 | if verbose: 546 | print("no suitable tags, using full revision id") 547 | return { "version": variables["full"].strip(), 548 | "full": variables["full"].strip() } 549 | 550 | def versions_from_vcs(tag_prefix, root, verbose=False): 551 | # this runs 'git' from the root of the source tree. This only gets called 552 | # if the git-archive 'subst' variables were *not* expanded, and 553 | # _version.py hasn't already been rewritten with a short version string, 554 | # meaning we're inside a checked out source tree. 555 | 556 | if not os.path.exists(os.path.join(root, ".git")): 557 | if verbose: 558 | print("no .git in %s" % root) 559 | return {} 560 | 561 | GITS = ["git"] 562 | if sys.platform == "win32": 563 | GITS = ["git.cmd", "git.exe"] 564 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 565 | cwd=root) 566 | if stdout is None: 567 | return {} 568 | if not stdout.startswith(tag_prefix): 569 | if verbose: 570 | print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) 571 | return {} 572 | tag = stdout[len(tag_prefix):] 573 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 574 | if stdout is None: 575 | return {} 576 | full = stdout.strip() 577 | if tag.endswith("-dirty"): 578 | full += "-dirty" 579 | return {"version": tag, "full": full} 580 | 581 | 582 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 583 | # Source tarballs conventionally unpack into a directory that includes 584 | # both the project name and a version string. 585 | dirname = os.path.basename(root) 586 | if not dirname.startswith(parentdir_prefix): 587 | if verbose: 588 | print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % 589 | (root, dirname, parentdir_prefix)) 590 | return None 591 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 592 | import os.path 593 | import sys 594 | 595 | # os.path.relpath only appeared in Python-2.6 . Define it here for 2.5. 596 | def os_path_relpath(path, start=os.path.curdir): 597 | """Return a relative version of a path""" 598 | 599 | if not path: 600 | raise ValueError("no path specified") 601 | 602 | start_list = [x for x in os.path.abspath(start).split(os.path.sep) if x] 603 | path_list = [x for x in os.path.abspath(path).split(os.path.sep) if x] 604 | 605 | # Work out how much of the filepath is shared by start and path. 606 | i = len(os.path.commonprefix([start_list, path_list])) 607 | 608 | rel_list = [os.path.pardir] * (len(start_list)-i) + path_list[i:] 609 | if not rel_list: 610 | return os.path.curdir 611 | return os.path.join(*rel_list) 612 | 613 | def do_vcs_install(manifest_in, versionfile_source, ipy): 614 | GITS = ["git"] 615 | if sys.platform == "win32": 616 | GITS = ["git.cmd", "git.exe"] 617 | files = [manifest_in, versionfile_source, ipy] 618 | try: 619 | me = __file__ 620 | if me.endswith(".pyc") or me.endswith(".pyo"): 621 | me = os.path.splitext(me)[0] + ".py" 622 | versioneer_file = os_path_relpath(me) 623 | except NameError: 624 | versioneer_file = "versioneer.py" 625 | files.append(versioneer_file) 626 | present = False 627 | try: 628 | f = open(".gitattributes", "r") 629 | for line in f.readlines(): 630 | if line.strip().startswith(versionfile_source): 631 | if "export-subst" in line.strip().split()[1:]: 632 | present = True 633 | f.close() 634 | except EnvironmentError: 635 | pass 636 | if not present: 637 | f = open(".gitattributes", "a+") 638 | f.write("%s export-subst\n" % versionfile_source) 639 | f.close() 640 | files.append(".gitattributes") 641 | run_command(GITS, ["add", "--"] + files) 642 | 643 | SHORT_VERSION_PY = """ 644 | # This file was generated by 'versioneer.py' (0.10) from 645 | # revision-control system data, or from the parent directory name of an 646 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 647 | # of this file. 648 | 649 | version_version = '%(version)s' 650 | version_full = '%(full)s' 651 | def get_versions(default={}, verbose=False): 652 | return {'version': version_version, 'full': version_full} 653 | 654 | """ 655 | 656 | DEFAULT = {"version": "unknown", "full": "unknown"} 657 | 658 | def versions_from_file(filename): 659 | versions = {} 660 | try: 661 | f = open(filename) 662 | except EnvironmentError: 663 | return versions 664 | for line in f.readlines(): 665 | mo = re.match("version_version = '([^']+)'", line) 666 | if mo: 667 | versions["version"] = mo.group(1) 668 | mo = re.match("version_full = '([^']+)'", line) 669 | if mo: 670 | versions["full"] = mo.group(1) 671 | f.close() 672 | return versions 673 | 674 | def write_to_version_file(filename, versions): 675 | f = open(filename, "w") 676 | f.write(SHORT_VERSION_PY % versions) 677 | f.close() 678 | print("set %s to '%s'" % (filename, versions["version"])) 679 | 680 | def get_root(): 681 | try: 682 | return os.path.dirname(os.path.abspath(__file__)) 683 | except NameError: 684 | return os.path.dirname(os.path.abspath(sys.argv[0])) 685 | 686 | def get_versions(default=DEFAULT, verbose=False): 687 | # returns dict with two keys: 'version' and 'full' 688 | assert versionfile_source is not None, "please set versioneer.versionfile_source" 689 | assert tag_prefix is not None, "please set versioneer.tag_prefix" 690 | assert parentdir_prefix is not None, "please set versioneer.parentdir_prefix" 691 | # I am in versioneer.py, which must live at the top of the source tree, 692 | # which we use to compute the root directory. py2exe/bbfreeze/non-CPython 693 | # don't have __file__, in which case we fall back to sys.argv[0] (which 694 | # ought to be the setup.py script). We prefer __file__ since that's more 695 | # robust in cases where setup.py was invoked in some weird way (e.g. pip) 696 | root = get_root() 697 | versionfile_abs = os.path.join(root, versionfile_source) 698 | 699 | # extract version from first of _version.py, 'git describe', parentdir. 700 | # This is meant to work for developers using a source checkout, for users 701 | # of a tarball created by 'setup.py sdist', and for users of a 702 | # tarball/zipball created by 'git archive' or github's download-from-tag 703 | # feature. 704 | 705 | variables = get_expanded_variables(versionfile_abs) 706 | if variables: 707 | ver = versions_from_expanded_variables(variables, tag_prefix) 708 | if ver: 709 | if verbose: print("got version from expanded variable %s" % ver) 710 | return ver 711 | 712 | ver = versions_from_file(versionfile_abs) 713 | if ver: 714 | if verbose: print("got version from file %s %s" % (versionfile_abs,ver)) 715 | return ver 716 | 717 | ver = versions_from_vcs(tag_prefix, root, verbose) 718 | if ver: 719 | if verbose: print("got version from git %s" % ver) 720 | return ver 721 | 722 | ver = versions_from_parentdir(parentdir_prefix, root, verbose) 723 | if ver: 724 | if verbose: print("got version from parentdir %s" % ver) 725 | return ver 726 | 727 | if verbose: print("got version from default %s" % ver) 728 | return default 729 | 730 | def get_version(verbose=False): 731 | return get_versions(verbose=verbose)["version"] 732 | 733 | class cmd_version(Command): 734 | description = "report generated version string" 735 | user_options = [] 736 | boolean_options = [] 737 | def initialize_options(self): 738 | pass 739 | def finalize_options(self): 740 | pass 741 | def run(self): 742 | ver = get_version(verbose=True) 743 | print("Version is currently: %s" % ver) 744 | 745 | 746 | class cmd_build(_build): 747 | def run(self): 748 | versions = get_versions(verbose=True) 749 | _build.run(self) 750 | # now locate _version.py in the new build/ directory and replace it 751 | # with an updated value 752 | target_versionfile = os.path.join(self.build_lib, versionfile_build) 753 | print("UPDATING %s" % target_versionfile) 754 | os.unlink(target_versionfile) 755 | f = open(target_versionfile, "w") 756 | f.write(SHORT_VERSION_PY % versions) 757 | f.close() 758 | 759 | if 'cx_Freeze' in sys.modules: # cx_freeze enabled? 760 | from cx_Freeze.dist import build_exe as _build_exe 761 | 762 | class cmd_build_exe(_build_exe): 763 | def run(self): 764 | versions = get_versions(verbose=True) 765 | target_versionfile = versionfile_source 766 | print("UPDATING %s" % target_versionfile) 767 | os.unlink(target_versionfile) 768 | f = open(target_versionfile, "w") 769 | f.write(SHORT_VERSION_PY % versions) 770 | f.close() 771 | _build_exe.run(self) 772 | os.unlink(target_versionfile) 773 | f = open(versionfile_source, "w") 774 | f.write(LONG_VERSION_PY % {"DOLLAR": "$", 775 | "TAG_PREFIX": tag_prefix, 776 | "PARENTDIR_PREFIX": parentdir_prefix, 777 | "VERSIONFILE_SOURCE": versionfile_source, 778 | }) 779 | f.close() 780 | 781 | class cmd_sdist(_sdist): 782 | def run(self): 783 | versions = get_versions(verbose=True) 784 | self._versioneer_generated_versions = versions 785 | # unless we update this, the command will keep using the old version 786 | self.distribution.metadata.version = versions["version"] 787 | return _sdist.run(self) 788 | 789 | def make_release_tree(self, base_dir, files): 790 | _sdist.make_release_tree(self, base_dir, files) 791 | # now locate _version.py in the new base_dir directory (remembering 792 | # that it may be a hardlink) and replace it with an updated value 793 | target_versionfile = os.path.join(base_dir, versionfile_source) 794 | print("UPDATING %s" % target_versionfile) 795 | os.unlink(target_versionfile) 796 | f = open(target_versionfile, "w") 797 | f.write(SHORT_VERSION_PY % self._versioneer_generated_versions) 798 | f.close() 799 | 800 | INIT_PY_SNIPPET = """ 801 | from ._version import get_versions 802 | __version__ = get_versions()['version'] 803 | del get_versions 804 | """ 805 | 806 | class cmd_update_files(Command): 807 | description = "install/upgrade Versioneer files: __init__.py SRC/_version.py" 808 | user_options = [] 809 | boolean_options = [] 810 | def initialize_options(self): 811 | pass 812 | def finalize_options(self): 813 | pass 814 | def run(self): 815 | print(" creating %s" % versionfile_source) 816 | f = open(versionfile_source, "w") 817 | f.write(LONG_VERSION_PY % {"DOLLAR": "$", 818 | "TAG_PREFIX": tag_prefix, 819 | "PARENTDIR_PREFIX": parentdir_prefix, 820 | "VERSIONFILE_SOURCE": versionfile_source, 821 | }) 822 | f.close() 823 | 824 | ipy = os.path.join(os.path.dirname(versionfile_source), "__init__.py") 825 | try: 826 | old = open(ipy, "r").read() 827 | except EnvironmentError: 828 | old = "" 829 | if INIT_PY_SNIPPET not in old: 830 | print(" appending to %s" % ipy) 831 | f = open(ipy, "a") 832 | f.write(INIT_PY_SNIPPET) 833 | f.close() 834 | else: 835 | print(" %s unmodified" % ipy) 836 | 837 | # Make sure both the top-level "versioneer.py" and versionfile_source 838 | # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so 839 | # they'll be copied into source distributions. Pip won't be able to 840 | # install the package without this. 841 | manifest_in = os.path.join(get_root(), "MANIFEST.in") 842 | simple_includes = set() 843 | try: 844 | for line in open(manifest_in, "r").readlines(): 845 | if line.startswith("include "): 846 | for include in line.split()[1:]: 847 | simple_includes.add(include) 848 | except EnvironmentError: 849 | pass 850 | # That doesn't cover everything MANIFEST.in can do 851 | # (http://docs.python.org/2/distutils/sourcedist.html#commands), so 852 | # it might give some false negatives. Appending redundant 'include' 853 | # lines is safe, though. 854 | if "versioneer.py" not in simple_includes: 855 | print(" appending 'versioneer.py' to MANIFEST.in") 856 | f = open(manifest_in, "a") 857 | f.write("include versioneer.py\n") 858 | f.close() 859 | else: 860 | print(" 'versioneer.py' already in MANIFEST.in") 861 | if versionfile_source not in simple_includes: 862 | print(" appending versionfile_source ('%s') to MANIFEST.in" % 863 | versionfile_source) 864 | f = open(manifest_in, "a") 865 | f.write("include %s\n" % versionfile_source) 866 | f.close() 867 | else: 868 | print(" versionfile_source already in MANIFEST.in") 869 | 870 | # Make VCS-specific changes. For git, this means creating/changing 871 | # .gitattributes to mark _version.py for export-time keyword 872 | # substitution. 873 | do_vcs_install(manifest_in, versionfile_source, ipy) 874 | 875 | def get_cmdclass(): 876 | cmds = {'version': cmd_version, 877 | 'versioneer': cmd_update_files, 878 | 'build': cmd_build, 879 | 'sdist': cmd_sdist, 880 | } 881 | if 'cx_Freeze' in sys.modules: # cx_freeze enabled? 882 | cmds['build_exe'] = cmd_build_exe 883 | del cmds['build'] 884 | 885 | return cmds 886 | --------------------------------------------------------------------------------