pax_global_header00006660000000000000000000000064150000072240014500gustar00rootroot0000000000000052 comment=ef18293c905dd9fde902eb20c718a0a465a9d33b automat-25.4.16/000077500000000000000000000000001500000722400133315ustar00rootroot00000000000000automat-25.4.16/.coveragerc000066400000000000000000000002231500000722400154470ustar00rootroot00000000000000[report] precision = 2 ignore_errors = True exclude_lines = pragma: no cover if TYPE_CHECKING \s*\.\.\.$ raise NotImplementedError automat-25.4.16/.github/000077500000000000000000000000001500000722400146715ustar00rootroot00000000000000automat-25.4.16/.github/workflows/000077500000000000000000000000001500000722400167265ustar00rootroot00000000000000automat-25.4.16/.github/workflows/ci.yml000066400000000000000000000025521500000722400200500ustar00rootroot00000000000000name: ci on: push: branches: - trunk pull_request: branches: - trunk jobs: build: name: python/${{ matrix.python }} tox/${{ matrix.TOX_ENV }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: ["3.9", "3.10", "3.11", "3.12", "3.13"] TOX_ENV: ["extras", "noextras", "mypy"] include: - python: "3.13" TOX_ENV: "lint" steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: Install graphviz run: | sudo apt-get install -y graphviz dot -V if: ${{ matrix.TOX_ENV == 'extras' }} - name: Tox Run run: | pip install tox; TOX_ENV="py$(echo ${{ matrix.python }} | sed -e 's/\.//g')-${{ matrix.TOX_ENV }}"; echo "Starting: ${TOX_ENV} ${PUSH_DOCS}" if [[ -n "${TOX_ENV}" ]]; then tox -e "$TOX_ENV"; if [[ "${{ matrix.TOX_ENV }}" != "mypy" && "${{ matrix.TOX_ENV }}" != "lint" ]]; then tox -e coverage-report; fi; fi; - name: Upload coverage report if: ${{ matrix.TOX_ENV != 'mypy' }} uses: codecov/codecov-action@v4.5.0 with: token: ${{ secrets.CODECOV_TOKEN }} automat-25.4.16/.gitignore000066400000000000000000000001261500000722400153200ustar00rootroot00000000000000.tox/ .coverage.* .eggs/ *.egg-info/ *.py[co] build/ dist/ docs/_build/ coverage.xml automat-25.4.16/.pydoctor.cfg000066400000000000000000000016451500000722400157410ustar00rootroot00000000000000[tool:pydoctor] quiet=1 warnings-as-errors=true project-name=Automat project-url=../index.html docformat=epytext theme=readthedocs intersphinx= https://graphviz.readthedocs.io/en/stable/objects.inv https://docs.python.org/3/objects.inv https://cryptography.io/en/latest/objects.inv https://pyopenssl.readthedocs.io/en/stable/objects.inv https://hyperlink.readthedocs.io/en/stable/objects.inv https://twisted.org/constantly/docs/objects.inv https://twisted.org/incremental/docs/objects.inv https://python-hyper.org/projects/hyper-h2/en/stable/objects.inv https://priority.readthedocs.io/en/stable/objects.inv https://zopeinterface.readthedocs.io/en/latest/objects.inv https://automat.readthedocs.io/en/latest/objects.inv https://docs.twisted.org/en/stable/objects.inv project-base-dir=automat html-output=docs/_build/api html-viewsource-base=https://github.com/glyph/automat/tree/trunk automat-25.4.16/.readthedocs.yaml000066400000000000000000000020041500000722400165540ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-22.04 tools: python: "3.12" # You can also specify other tool versions: # nodejs: "20" # rust: "1.70" # golang: "1.20" # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/conf.py # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs # builder: "dirhtml" # Fail on all warnings to avoid broken references # fail_on_warning: true # Optionally build your docs in additional formats such as PDF and ePub # formats: # - pdf # - epub # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - requirements: docs/requirements.txt automat-25.4.16/LICENSE000066400000000000000000000020351500000722400143360ustar00rootroot00000000000000Copyright (c) 2014 Rackspace Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. automat-25.4.16/README.md000066400000000000000000000137121500000722400146140ustar00rootroot00000000000000# Automat # [![Documentation Status](https://readthedocs.org/projects/automat/badge/?version=latest)](http://automat.readthedocs.io/en/latest/) [![Build Status](https://github.com/glyph/automat/actions/workflows/ci.yml/badge.svg?branch=trunk)](https://github.com/glyph/automat/actions/workflows/ci.yml?query=branch%3Atrunk) [![Coverage Status](http://codecov.io/github/glyph/automat/coverage.svg?branch=trunk)](http://codecov.io/github/glyph/automat?branch=trunk) ## Self-service finite-state machines for the programmer on the go. ## Automat is a library for concise, idiomatic Python expression of finite-state automata (particularly deterministic finite-state transducers). Read more here, or on [Read the Docs](https://automat.readthedocs.io/), or watch the following videos for an overview and presentation ### Why use state machines? ### Sometimes you have to create an object whose behavior varies with its state, but still wishes to present a consistent interface to its callers. For example, let's say you're writing the software for a coffee machine. It has a lid that can be opened or closed, a chamber for water, a chamber for coffee beans, and a button for "brew". There are a number of possible states for the coffee machine. It might or might not have water. It might or might not have beans. The lid might be open or closed. The "brew" button should only actually attempt to brew coffee in one of these configurations, and the "open lid" button should only work if the coffee is not, in fact, brewing. With diligence and attention to detail, you can implement this correctly using a collection of attributes on an object; `hasWater`, `hasBeans`, `isLidOpen` and so on. However, you have to keep all these attributes consistent. As the coffee maker becomes more complex - perhaps you add an additional chamber for flavorings so you can make hazelnut coffee, for example - you have to keep adding more and more checks and more and more reasoning about which combinations of states are allowed. Rather than adding tedious `if` checks to every single method to make sure that each of these flags are exactly what you expect, you can use a state machine to ensure that if your code runs at all, it will be run with all the required values initialized, because they have to be called in the order you declare them. You can read about state machines and their advantages for Python programmers in more detail [in this excellent article by Jean-Paul Calderone](https://web.archive.org/web/20160507053658/https://clusterhq.com/2013/12/05/what-is-a-state-machine/). ### What makes Automat different? ### There are [dozens of libraries on PyPI implementing state machines](https://pypi.org/search/?q=finite+state+machine). So it behooves me to say why yet another one would be a good idea. Automat is designed around this principle: while organizing your code around state machines is a good idea, your callers don't, and shouldn't have to, care that you've done so. In Python, the "input" to a stateful system is a method call; the "output" may be a method call, if you need to invoke a side effect, or a return value, if you are just performing a computation in memory. Most other state-machine libraries require you to explicitly create an input object, provide that object to a generic "input" method, and then receive results, sometimes in terms of that library's interfaces and sometimes in terms of classes you define yourself. For example, a snippet of the coffee-machine example above might be implemented as follows in naive Python: ```python class CoffeeMachine(object): def brewButton(self) -> None: if self.hasWater and self.hasBeans and not self.isLidOpen: self.heatTheHeatingElement() # ... ``` With Automat, you'd begin with a `typing.Protocol` that describes all of your inputs: ```python from typing import Protocol class CoffeeBrewer(Protocol): def brewButton(self) -> None: "The user pressed the 'brew' button." def putInBeans(self) -> None: "The user put in some beans." ``` We'll then need a concrete class to contain the shared core of state shared among the different states: ```python from dataclasses import dataclass @dataclass class BrewerCore: heatingElement: HeatingElement ``` Next, we need to describe our state machine, including all of our states. For simplicity's sake let's say that the only two states are `noBeans` and `haveBeans`: ```python from automat import TypeMachineBuilder builder = TypeMachineBuilder(CoffeeBrewer, BrewerCore) noBeans = builder.state("noBeans") haveBeans = builder.state("haveBeans") ``` Next we can describe a simple transition; when we put in beans, we move to the `haveBeans` state, with no other behavior. ```python # When we don't have beans, upon putting in beans, we will then have beans noBeans.upon(CoffeeBrewer.putInBeans).to(haveBeans).returns(None) ``` And then another transition that we describe with a decorator, one that *does* have some behavior, that needs to heat up the heating element to brew the coffee: ```python @haveBeans.upon(CoffeeBrewer.brewButton).to(noBeans) def heatUp(inputs: CoffeeBrewer, core: BrewerCore) -> None: """ When we have beans, upon pressing the brew button, we will then not have beans any more (as they have been entered into the brewing chamber) and our output will be heating the heating element. """ print("Brewing the coffee...") core.heatingElement.turnOn() ``` Then we finalize the state machine by building it, which gives us a callable that takes a `BrewerCore` and returns a synthetic `CoffeeBrewer` ```python newCoffeeMachine = builder.build() ``` ```python >>> coffee = newCoffeeMachine(BrewerCore(HeatingElement())) >>> machine.putInBeans() >>> machine.brewButton() Brewing the coffee... ``` All of the *inputs* are provided by calling them like methods, all of the *output behaviors* are automatically invoked when they are produced according to the outputs specified to `upon` and all of the states are simply opaque tokens. automat-25.4.16/SECURITY.md000066400000000000000000000044641500000722400151320ustar00rootroot00000000000000## Security contact information To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. ## Security supported versions Automat is a [CalVer](https://calver.org) project that issues time-based releases. This means its version format is `YEAR.MONTH.PATCH`. Users are expected to upgrade in a timely manner when new releases of automat are issued; within about 3 months, so that we do not need to maintain an arbitrarily large legacy support window for old versions. This means that a version is “current” until 3 months after the last day of the `YEAR.MONTH` of the *next* released version. This means at least one version is always “current”, regardless of how long ago it was released. The simple rule is this: **upgrade within 3 months of a release, and your current version will always be security-supported**. Automat releases are also largely intended to be compatible, following [Twisted's compatibility policy](https://docs.twisted.org/en/stable/development/compatibility-policy.html) of R+2 for any removals. Thus, “security support” is a function of breaking changes and time. If a vulnerability is discovered, all versions that were *current on that date* will receive a security update. A “security update” is a release with no removals from its previous version, and thus will be installable without breaking compatibility. Some examples may be helpful to understand the nuances here. Let's say it's August 9, 2027. A vulnerability, V1, is discovered, that affects many versions of automat. The previous two versions of Automat were 2025.5.0 and 2026.1.0. Because it is more than 3 months after january 2026, only 2026.1.0 is current. Thus, a security update of 2026.1.1 will be issued. Alternately, let's say it's December 5th, 2029. Another vulnerability, V2, is discovered. It's been an active year for automat: there were lots of deprecations in 2028, and there has been a removal (a breaking change) in every release in 2029, of which there has been one every month. This means that `2029.9.0`, `2029.10.0`, and `2029.11.0` will all be receiving `.1` security updates, with no changes besides the security patch. Once again, just upgrade within 3 months of a release, and you will have no issues. automat-25.4.16/benchmark/000077500000000000000000000000001500000722400152635ustar00rootroot00000000000000automat-25.4.16/benchmark/test_transitions.py000066400000000000000000000012511500000722400212500ustar00rootroot00000000000000# https://github.com/glyph/automat/issues/60 import automat class Simple(object): """ """ _m = automat.MethodicalMachine() @_m.input() def one(self, data): "some input data" @_m.state(initial=True) def waiting(self): "patiently" @_m.output() def boom(self, data): pass waiting.upon( one, enter=waiting, outputs=[boom], ) def simple_one(machine, data): machine.one(data) def test_simple_machine_transitions(benchmark): benchmark(simple_one, Simple(), 0) automat-25.4.16/docs/000077500000000000000000000000001500000722400142615ustar00rootroot00000000000000automat-25.4.16/docs/Makefile000066400000000000000000000011371500000722400157230ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = automat SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)automat-25.4.16/docs/_static/000077500000000000000000000000001500000722400157075ustar00rootroot00000000000000automat-25.4.16/docs/_static/garage_door.machineFactory.dot.png000066400000000000000000001405431500000722400244150ustar00rootroot00000000000000PNG  IHDR4za~bKGD IDATxwXTG.P"(`a!-c-QcF%b!cQl M,{qww8sX8)ˢ( A`3-@ 4 1' sB$D + EA__XTUT}-/Oա--oyy7A0޾Er2ddf"3ٵJJhZZ 02WA EllW\P^:ed /k?342)&""w/.\@UU))L5kо=_FyI% gg.{ O;X 99FMJJ+(6j쨰0e֭]NTp0Ӳ!=gh(L7غ2IŶmصv"Ys'deVF3?I^K.f3R{iet&¿90w.2cTCQؿ˗ڵC@zbZAIQ>odևg<}Q%(+#$=z0#2;ƸqL ;>|nMMsg%rDÜ~ii\gg5=k֬wү?~|EEŋyUaYq#.JJdD BRV6Gԙzꥪ `Ϟ=/dǏB99\A/b~˗SҶm[mmO23KKK߾}j_Fn(, EgTkiluJС Earմbݜ$//>n1}mllLMMǎ{Ŧ(&MԽ{w;;;OOw+ڽ{.] 4hӦMʼz{'OWݻw5kVFFFS3k * Wansҷ)(jq8jeɒ%,M6Ç7n?+aff` ꮮZZZO<8s UU!CL:Ύf}ի*** MMMiisq ,^@ǎ'Nhbbҹsgmmm}}W7js~xz+W۷7 9sss۵kNߡgtez%!! Ԭ,eAAjRR}ÇEdzX^zSU]]=f3gVV?hZ |6𫉞={JHH$&&~`ƍuoڵK.%%%_|˖-VZUCQdϟ(MM kR 7<$eRsCC-XnOMM@RSS?~G6mnJ9@ll,ҭ[79>RSCNcPOqC|3k,++вJVݛܷxzz;6$$ݽCVVV.][>-- ijjڶm[ջ9-`#=~R:kI%O~sЦMܰg=ڵkCehjjjtu{zGSܟAu٣GYkRnRRRc(**|~ԍާGfG} .8u-z\^/l^%3b  fcÿF_C٦  !vC6ϙ4iRaa;ͭ,aNNNbbbS.>00PWWk׮E]]@YY;ھ};jaa!]cǸo9rlؿ|me6NNZXPX__ԩS/\?ZZZs?@۶m-[e˖7npߢgqƍtI3gr?nL:nj¢Dnnɓ%%klKKK?/Ν;KHH?>~II.UTTL,S'8ƞ={Up|||gKn=nnn?YYY{ߒ?uꔑ?̙Cm[_ E7x66 \#_ZLJJ*..622gJiiϕ$>R^^mmm}}}/^W455,ann.ş66pɄ \L4"`Nk`&PT͛tM3jjȪ&?ӚZ1B=!e:P\aʴ q':SI Wg2hSB'OLK޾E~8wq|_@Gps=K-ٜ\ ܼY(*eVV%,,i< !ϟ#6x11h0Cnpq ixqUKEpxs)`}SSFF02BN/~dhhCETz.s8zyڷo#&i=$$|ttѦ TTjԠ҄n}";o޼M@_vvv@#Vd΢9s8qbSEp1]i+>OF'v,((ںt<ӳI۷8p)KQlݺ?ϴPYYI'G bXgϞeZ 9 njC?d0-Yp}aZ#lmJJsrr?>l0ovر 9gƍV0gӇbEFF3o~Ĉ_ nccC'\!bb΀GGGCCpCCCW5f6,$$D 'Wooo6CbFΝٟm:ujUU|ȴ4/ɥ;99eee]~Ғi9ɓ' r 7nyyrss###544ChB̙$!!J$KJJ\]]+&3"k|GGǒzkzƠw…,]i- 欨3fLff7:vȴQn{eZ l-EQ?Ço޼IbiӦ=zӳsgZ O?k׮Ϗ=i-@uuСCccc###;uĴBc:s8p`֬Y{7oZć\EEEp3""bРAg&\xNbb?@BȜz "A``ѣ׵k2mŜŽ{v|cͿk``X(3Ŝ&M|rTTTΝ"PE9Ba}͝;=Zğ"))K6H&7g\\\޽G2 $0mll~ ,,,l۶mXX4JZw?>Z … ?|piL3f̘E-Y$<̘ԩSAAA>>>$+ܹsGaZ 暘n%Nz]2P 6mZ@@@||<mmm###!Aڻw9rˋ8S={ׯ,XB-99mMMMTT9$:uȑ#Lk!6LѣGˆ3'?sε&#cCs 3%%%zb>$"lΝyyy[nXfpٴŋ3# sܹsΜ9ilLMMw}Ǐ3U#aڵkwؑF SLpBTTN0g~~?}v~E%%%֒>cZNkDڝ;wVUUݛ𙚚dRnΒ3gjjj-o133޿sNIIС_" &ܸq#..L `_+onn*!!VG -"ݻ0uǞᘙ'1`w޵fZKkճgO>5AUUUvvv111deE0ќvvvnSгgӧٳi-~=sFGG߻w'v\ri-~'NILL$ F9 .$3X,_bMx"/ٵk”)SQ9Y455ׯ8pi-b%%%?ömx[3Ax={c=zDR6ޛСC3gLKK#Ę=z())ݻwOJJi9 rrr"o?KB[3))޽{ӦMm!gϞ+VظqǏ"xX?8p 33SFFʞ={JHHDFF-eYSSsI&g9s2xiׯ}i[K.ݰaӧO"nrX;~G),,U۷eZETTTSJJÇdpK(Q^^uV@9HIIoL +x!88hԨQ;fffOHOO++%K_~ĈfffLxf@cc1NUTTϫ[3ϟ?gZ·Y~+WL2 < 2dj#222W\\f+xs{.))iӦM "f[9OjmLx,bZEcYdڵknjjʴ7挈ໜ%Y=V LВ&l%DTƍ^:cƌwP}-7|ivT-.=C8 l2SSYfq81ѣcbb^xZ+***lllƎ;vQF%$$lڴi/;pB.{"9 o6! 6LCCcǎ~~~|}˗ u=nJJJ.{Hcǎnݢ߿^pp*/RO+;Wq(z79t`4'EQ&nӦ֚5k=ڠ98p E5œϟILL8q"b̙sݛ7or]GkN‚Ǐ{{{Ӈ.uO>>Eǻw}i=z9^|]eee:::JJJu $'N* ȑ#u[y3(NUUU9s\t?!11QZ IDATFF(ux ĉ矔(3ߊϋ &p/;:87o۶G-[6gΜN:ݾ}nWWW]4`ʔ)7n޽3gZxdӧuq988HKKGGG}MhpC>}8qB^^ŋ/^u־}dddl2;;h ;w^dɖ-[&OܱcG7g#խ֭[>}̙I&رcǎzzzC﹧I;na$60M0l-{988̘1)RRRׯM)))gϞ}RRRu8/mSVV߻woԠ ???{{{{{{oo=zDXz'.]zyϬISSS`Gz:K߾}oܸ1|__߾}n߾~WRRjr>}XYYedd4hzBYY7Bdff*++ K%%?H5kg\\ܕ+Wzy暚fT%0l ŋ~RSS&Ţ^o%%%q_;99ٳŋߗiaHKK]סBB>yVLKKԩSK6rqqyƍׯ_V]]Vxĉxb0f@˗߼yӫW/ƕ@AA˗%---wlll:uԤw_eddXXXhkk]|檪u[K~II+V\zCʱX,oo/^`3'͞:uƍmll=z|rmm7=zI/_wС={ԩӮ]훛{ĉ~ܹܹs벲8---:[022p6=f̘zVZlСCW^dɒnܹ-ؾ˙3gY[[/^mKKiӦ&L`ZçYonB055]nwNCoߦ߭2e ܨ! { ']v={L F1׃ZMqq1wVh0aªU"th NB$6%edd$~K.^^^֭cZp!sJO$R[)ڵkb%A-Z5g---0g;;v}H©fYYY)))Q b ??M6TuȰm6 !MW]]]Т|իW/Zi9‚~xl -9-P ݻwݺuB<+HeN ZmԖxfĜx'ʮ^zΜ9K,dZP@6!'o߾ſsŒ3:wf ĜIFF4 u]z^DV 1xV/H֣G_i!BA3gEEETT?r%%%9r6ǏHII1-iXׇD[9kΠX==3gZYY͘1<^x! zEE_VX1x`tbllMpuu9rdZZZll<66Çh'??رcuD|ѣ=<<~7MHݎGBؼyJuuu# !tĴ&acIIIII4 bIIIl999999:Q>|allӺXYY-YDGGNy YAAUӧ^9tss377ի=eX*** W:$$dŊ˖- ܹsA}njŝ'?a׮]~vΝ]v}6:::~!uuuܽEEEݻw_):霙tV[n˛e?~FNHH(--qIJJ֋!JJJ ̺ubbb1y{={vԩ eee[l ܹ3K.իN(Rz5X4+++/_JNMM +SLmΝ;$''wܹ_^^^N?4 pppؼy Z366JZZIII{&...77p8t܍F櫮WƠA@/_q@``Ǐ߾}}{>x`aa!ܸ صkWQQo݌׼ҥKݻwy͂~0ļ98miiikkK?Njmmf͚۷^WikkwӓN @__sݻw;~gϴMMM/5oϞ=ٳg_ Aڵ իWMUUիWG:ٹGYkOM( 55|j{mii)K.AAA MMMlaRUU}}%5[KZf4~Z`O "##tZ [:vxi+++& 9~)w~K ;vm"h2uuu֜gN:5j(nQ*** af.]z%zЪYʊ.9Ν+//KI ׯ_uǏ3ES|?]\\DucZt3g^~ʹB)&DDDxzz2-/L6MUUu׮]L (Ĝbž={MJD5kwek@ϜBCiỲ;w Œ xyy`iV@@ P ?~6mZ#׊Ee˖Ν"bN9Yr.gΜə;w.BN.] m6www1s!B"Ϯ]L{~'OБ1>"Q8JܫWk^ح[71E˗yrƊe {.#?\p˗/oE=eNvڰa A)..n߾ƍ_9E{{{CUiU,Xo޼U2!$ܸqݻׯgZxzz~L /U[UUu}0SIIxH) P1o޼?zi!|"CUUՃ555t6˗#""Z& ӧObN`޽{ 7 ŋ333yۂqȰV8}thhqfL8b={i!}zxxxRRD$=r̙g׮]ęaҤI)))L RJKKMLL.\рo~ɿ;Zx9 6~ΝL lQΝ;'6 10?_~cZ(Ǐ3-7aprssccc;#ϩ~͛73Sؽ{tPGdI ̺v?n,\ŋ^bZH!='xzz_ę<"1;{nWZZ:$$i!͇ k!??200i-b'='3̛7dL g pL9'OaZ83p? bNA9{cǺ3E̱UPPSPɓ6m0-G2d.xSڵ~~~ęaРAaaaUUUL iĜ#>>_~Yh3ZZ +..ѐ_Ĝ]__D9$VVVrrrgZHs k֬ILLp(ڽ{IƱ Z#[bΖrCHFڷoyd===eex4bի;FIII%%%__ߋ/a244LNNfZH -… ,''{l68ɉtBQFFFqq"Z9O@@BRRRMMٳf)JMMeZHc!l>t9zD7771z$fwmuu2-m4449{dgG塨cWUUU)l6TTj_+(@I JJPVJkUUm mmhiƍ1cmV+|iEY^/k%޼AVrrPd%%cGA|ң ^SVb|d:}B[_ځl:Nڷo"G|;!3  Bvvm1ff01) !)ԟtk}"H~e HDF":֊ ff2Ne'%vЮ,,x^=! ؽ>4LMѫWW׮hh7.z7۷ Ah(>ċp=1y2wG׮22Llrr01'7߿dzgǣGwvv8}@V!ŭ|QYd&t^nBHBBlOkkt@(H<|dgCV}`@8:Zlq!!,,_~;wfZ˷9߿ǵk8w7n:83#^Zkgga`,n8p Z ̙'p,"#!!1v,E#55x/" ))PQ CS^R^^.//a|>>sVW57\ap0TH#!>}Чmӧx::i02bZX ++͝Xn򈓛ѡFDz: 0y2q k .11=sg WA*UUռ`<,^tDTn90GǎO^2QUUع8u 7+l#0O 'QQLkAZQnݰ|9&L@R.(0ffGh(JKacYPR´&U};Ǐ0O<=!RFYPgg\%K33ު"| ̞L DȜYC+ܸAx.q>.?3-HQVV.**bZEhrϙ~۷[3cbb r|iiލ/Xi5BTUUHoZYUWW#, |$\|ƍ `Z7XX>i5B4*04sYVLFFF7oׯB͢EHLĂrdee ѿ?v|Dh>eera.\Gu߶քUO M4{vvv޽W<<JUVVn׮]zz:}-8@y @SSSUU5)).}}} \+7}Ny*g%s~գG8ĉRl6UPvD)))L 65;շ/_P>>>6nX-]isV2e pl`ժUu+p!\yy9}\VVC9Rɒ%G*1 IDATKc) w;"IDD|~m&Ăv#[[qoZ[[MGkmm}ȑ{{8#Gߥ(^t^X 222Ϟ=kTB'ҥBSh} )QuNO6:faTܷ N_՝(sssSepׯ{)%%`J233?QM'eeHP?CH_@UU@Uܷݬw M6 +[hhhH'3 $]JxRkJJJݛާhtȅ~ѡCtt> mtϽݻ4}ڴA׮hGk# '(ew@]]ݮݻw Y,=Tѣ=Aۛ78rƑM"Oc3va,)=lϞ=Gy &L:5??ӦMeeeWJIIiС6lPUUIIIYt)=={}?vիW'OnݺEx)Ǐ8qt#p:88\reСϞ=y&WAv6%4~bGJWߟǼ1EQTIIWTT[^JY`3bl6ӳ&V'ΟǏ*̙3lkk+%%ŽRaWRQQ1o<---iiiz&SN< :-_ "ϴosRNIJR'1DFF&$$Tg6E({AQQїyUXXXbbb_7O4GGG>GR66Te%ZDȜM;boMr%-,Zws:t@1EQz,=,sNIIIϞ=}>?7n 49Šr%u+3gά>Eǎغ<117]E_DL:b/C8s5 CW3f](*_={cKX99V ?ӱ|9BCao.]m22xޔ <ӧ];̚ee?/0k9يL֬AZѯ6l@05źus 55…}"8!%׮Օ:% 0/u#F` A^^ 5_-ܸkPP1cл7y=w޵*^dN.eey֥YYA8gOGyaa [HI0bFFnLk9EZ[ IIG]@JHES'8:A㏋ ĜMxċp SS05!o~PY$<gϐ()GX[}m= Blw#!=УBDG#* ϟ#&Ǐ׆SS:vDǎׯ}ѱ##=xEZj7K}LM1t(am]!!Z!欇rS( /k{T#:oп7UUYmmm mmhiASmBUPR"Ԡ&L>塤EE(.FA P\| + #;w;Jퟌaй3LL`f:! 0PScǂ~a-->/jjV$?KJ6h::8!9$66s#Gn3(*BQ^EA?bWW^q (+׾k^%%BYH E$KӧOK6hM@^4|< JbǏz@`9F{]Ā%DYSS3iҤ[nh9qG̹xK.KH %"c-[ٳgԨQLk!hL:ujժU֭K!"Z"`۷oO6mk׮eZ 8ݜϞ=3fϴA9322 f``p/m6 5g~~!C^*T!QUUۻwݻEZ%Bj΅ ޹sEM hΝ;w۷K ր=s^~}ٲe+W9s&Z&.sO0aȑ7ofZ 0Bdw9;;wرclq G 4 a@YYѣkjj)MFX&fΜٳ0BPsǎ'N8uꔕZaampp???i-9'N8hР 60@64gqq#ڴisi v@ƞ9)>}W~xǎk׮֭%Z'<3gEEٲexU'КٰvݺuQQQd@K ޘ3::zǎ6l ZW`XpΝ۽{w2%xz΃FGG߽{WRR(rAK{UVM:w<D hZj_~r>$ z9>~ܹsGUSST H9LڵkMMMW^@zs^t:qC/{Ά;w.Xɉ0 #8;VVV׊0MqVVV77а˗eeeˋ) //ׯ_gvAb0111YYYJJJy>|hll466f0vvvʽb衫W*))Yǭ󙙙I>lX~vN<)&&ƊApW\aǏ0%%|i̘1>cm}v={&OsssFFFqqqwAG# ܹ`t CO0NUm> B 䧍@ZZ ]qqQ[[gGQQ{߾}pر/_v^ϟqƽx񢨨( @QQQBB";; ---QQQ__k׮ihhP(/^K*jbbrw]zuРA222A3FLLL\\|۶m AAAD{)ܹs믢veA899}vѣ.߻)S}쯶TNee%'O9lll ޼ymccCN}gee轓vt,dq?w^ロBR#G ++0a˖-ͦvqoйs>}W_> YXXhkk1*++\]]]]]ٳgcƌ9|͛7|4x}ݺu˖-7n޽{n`\`KKK99Haaa0g!Nw|{{{PWW744LNNk'O :رc]}ȑt:?h˗/lmmTjwfmm}Q3zzzy/l۶m׮]\ y:y眿;DDD>}FW/ BǏӧUUU?}D~{HHXYY\pAYYY^^>77 1ch4ݽz ZXXH[.XŋO< .t˗sss_~w˻|!' .\rKG'ܿ?i݇W5!--6˳}ر_&_rHOO4iRFCN">>А3gt޹ZA,_\RRݻwX QqRh{CCHc+Fv;hOUUUXWWgaaA~I֖NIIillns߼ySPPgeeyyy***FFF4ї]477'%%h46{廉'a gHK ***RWW 2eJXWWG.>x@y䉓SZZ),]@pAENNi4رc;y{n3ޕk.;HII۷oΜ9qqqxmpf͚ebbr!A.pV\Ѐ: ^,ϟN!ȦOHx/ԗch0322"Ca<BZjT\Q_kiii q SބTI?-T!ٳg9--Ee5Q #+^s"O9@UUUQTs2\ԇ̄.Ζ]vSђ222.]ԝo>>>޽E .$''_755+&urO^^^/#SÇyXO{|K̙3<=u$HLL^rͲe8pp/}];tЦMٺH䴮]}~zOOτԻwrv~{HII2d X8gϞmffVZZJ~qꫯطqtt$`hK]|Ygff~²eTUUg,#F_x1_IIIll… @YY- kbbjf.ݒ| d0OOŋs---9 _DA t:J444d2\¾ebbEDD :ڒ GN:)44fϞ=mڴ!C1c7|STT/?+J@UU?FѫW"##_|InI[xqQQ޽{`j"H'z9h~~~E`_skh4:!B9s,--mll#,//0aBG-?>999//ŋ#oooUUm۶ӈUUU3acc3fWWׇvlOŗ[~UUUoooG)JJJh+-;rݺu?g-wJ$$$j@TTnZWWG >‚bȐ!O>%ٞ/LOOohh011aa6Qtt3ĸG}}}O<{zz?AMM ,Yf:<###*&  %;;rrrXVEYYYC پ366$Wf_xA*c!$&&JKKw~ܹ ?25jy:_WWƍ# 7oν{?~Q}'**BɸH1^8ݫzꔔ]]]]]]ED#yѾ^jnnN^!߱cǑ#G~7֧ gΜVUU:tYsYXX ul/۷/&&F]]=::ޞ UTT4Xԕ.rOuuuVVYYYOD`džC຺4 ENNlt IDATmZkccpB|t9@FFu;W@! kjj~ƗOJJJ~yf( ӧO:::7nܰQTT{ J7kDWW7***..nƌY;8U8{olll_pƸbWXX_u .䯿9s& X@].++:-<+R.?1z|izzzbbb?6c}'溞L2rUyy'r/Y\+N=|!m7_BLLLNNN_8s/JJJmNr'O NJSBBwBޟ'77NO,,,sΑ#G(JcũNqӧ-Z:3S577={ӳ`<KKK׬Y:c8ܩS\]]ɹ0Jd_~Y`` XoS?~\WWwɨ`S`}᯿ڰaJE \222˗/G%\6mB%\СC"""k׮E=\̙3֭#.Nt ߢ .NAS__ɕ+W*++΂ .NATWWoڴ upq Cyzzjii΂.Nrm۶q.NQUUua///]]]Y0)8~&m \ĉ6mRQQA \_ټy3 Ν۶m)~gu֡q 復^vĉ`{Ne]]]|ߦ='},ន1M6=zƌ`{N>vϟq9Umm%K 6 u+pqݻwC\|)''[nD\|i˖-x`OLLL@@K$%%Qg|uݺu .D.s?#%%%../S-pOJKKUVۣ΂q.N~e!!]v|X7>}zeEEEY{Nd27nhccl2Y~{NpW^=}TH:Pi>P^^?/[u}A{EW=~gϞׁs򴦦իW;99-]uនݻ7;;; pɻ2228uV333Y0pq( 455'>QϟÇQg='/֭[,Y2n8Y0dpq7qAA0a- vڕ+W9yKMMի'M':pq-[TWW9su =\dgg3 g?~~---T0ށ۷><##|ظb ggիW \h0̰/_ZXX8q ~ÇO#0ZFRRRee%455}gϞMKK۷o)h='>RA4AHsa<'aaau`0 [AA`ʼn@SSSLL 򥨨(ss7n*Dɓ'MMMZ]]}~N\<|PXKqT*Jρx" _E 44VQQ>|8T=gLJJ)Sqeb,8[DD aaaQQѣG޹sG^^a0!""T*U[[; u.c,X+k9Unnn^^9sfŨa gz!4\mAI TW 6 D 'm$@8Ɂ?rr** &C//Ç9. lnl̄Lͅ(.w;"" # (w"hlue%45AM ֶݣ"%.>z^##00%%K lmo!1̄ % 0n > Ʉjj {|'rr```i VV`m |˃W %tP@O,-a<00CC0455]H塓-t̄? oC]XY#vv"H7Ah(@t4ffVV`e #:bǘLx!! .jj@\\\`(E=y)--klmO}[[prQɉ xbb> #0y2L -\\G ׮TTZWW{-1}xL1΅AP'78(3.^k /tta 2-Vy90q",\3gd|o<|s炩)> ٰg 6P* !(JJ9hlASn>>T*UAAaǏ' z kB$%%ǎ;k,qq6ߗ:uܹsEEEX{N;j(Lܹsi&Y`~8~~ɥyZll,5d`22įr uu\۷oIX۰/___dIII333&IDuu\FFOtuu+**g .+;Q({{: Z[[gΜ +ΆF#R< g22""+:u v#ʕ,NmmmIK, ?#{#ϓht:|Nkii͛ߟ7oZA8:kwQq"}<JYYY@V#gϲYOQ=]tNNNIII3m4U jkk 11e'''ΛSRRȇiY :FйAV\GPQQaRYY'u&c@^^^m;6g###e(""Z;2ɗ b $ +@]c6edd`РAp!}}^'N#䲜uuueWXkdsq<\d!?=KNNhiiPnnn_trwI}otHJ++dIpq777 *LYYٽ{455̹L7nHJJ:99W_}EP~w4VGJw2e \rK^{#$xIbԽ{Õ 0s 믿LJyVFFFOOիAAA&M8;;_rǗ/_^rԻw ȫ+WdߵHa0 e֭>$$$h4:;#Fpm^GWkQ'I FXZ͜o~ҥBB +87lذl2r3!!׷%d:uJm))9s攗t8l ÇJHH\~АKy @rm>޼!DE_~V%G"sƍATVV>{v򢣣߾}0]6RXXŋfn_RBIX{{u|TW:m_pw|vDNN 6}e#jjjj\Z `L#GcO0kxx|^_㒖3޼@>^i@A_BBp2 :3#{ CX}_ qqp \ 0~ȀXqq=NɓAOu8Tx?'O10*(DHLWS()*,- ƍq14Xx 2203 0604d3=@bA `m Çȑ0r$HKI(8yb;Q(`nj@C$'Cb"$$@J ;vWIP^Mjjjj +ۧmnjR()  ;DEAO`ݺ=<8ې`23!=i۶ '"#{ @NdeJ k.~>d0UU@PW&&.ad00GK T.w , }[^cc&(.VY UUniO)VW @ Dv #ו\Պozl'\~?a&hk04fgXb…K.E7nܹ>>>`XaҥK?~ Y0C89o̍0sag϶n=k,Y0 8KKK=<: q;v,...::ZJ 09駍79u 0>.N&7(++ڵ u <>>=qDllldd$^DsرcڵΨ`WeqrJEEŽ{΂a—OxB 09 nݺrJ|&&87n())~A06,,xKLSԴ~ѣGϛ7u :~9ٓw]<6Mϙy[΂aok[nE ^~\]S\\u '|s666nݺu&L@Ç@YZZz 6΂a׋sǎT*u۶m`X Biii.\8|,xܼyիQ0x };w'ģ='A;v=zQg04xW"##Q0dxܽ{#ƌ:!Ë=ghhh|||xx8 /Q0x  A㹞sϞ=x;V:0൞̙3SLA⬪y󦗗JExbkk7|:*γgzxx(++a>>Aܜڹs' 3f Fy&Fcmk.oBBBڼ͛7@rrrXJKKyHn|I[VScƌ~𡜜k۷tͼ:,qҥ]XHKKCkjllR666***F})1c Μ9/_&9λnzϣ IDATɇҍ '|/y(**QQt=zdvvvii^_ꮮ7nlS'Nwa```~~=ȨucZuu5k}ZZѣGYࠧZ? TUU;B7RNN.22=OXX̙3/ ;abbǏ:uÃ콱!$${nׯ_oٲEUUu䫎/_^p-[*++ϟ?Oɗ w1]]]gg犊k׮w9.#G_766%%%v WHHh̙m'Nܱcjtt͛֬Y[(ʺu ̙3sw>7(/w[5!--(K,!edd.\|O̙3kjj%!y9x{{}BlmmWXQTTt:UHHu!ܿ?Y݇XիW,)""iӦl)N """t%})>>͛\z cGz=B?۷onS@kI4q󲲲6DEE=HC999rA ޴iӄ 0b& 0:zhqqQXpqb]_rɓ'\G355Hq?}:")%%L"G\|u_/fxJO06mږ-[ꤤ8gY|&OFEEM:u{n8(_%ˠN.={I-c8))@? 940tP) 9֪ueq \? t{q[\g =XrX,HM_JJJt0Mʼn9ⷴuޓ6s&\X@mm- G~H5z}ȼygϞ-''gmmQ Gݸq#y SDDdС+**6m>\BiI555_}ٳg8::jjj۷|233sĈ򞞞/ȏ#dqru/ܦBs<|IIIrrXdd$zիW^ <`ڡ^=z3l߾}Ǐ捉I-466ZYYu'ʕ+ HJJ_dСCGeZtTTԽ{9z2;;ӗ.]jjjrrrZd-[ oݺu…۷qG9 A9EEEuJ( Fh45eϟ?/((022JNNfcccyf rv[ D߿qF---rܗiI EVV˛T̙ciiioooccѣ~AUUۻ&)**r~}/='9۪ŋ;ȑ#G"ܗ B)//rKrLKKK򡕕CkQn3'7kjj!%%%s"[QQ?._ǏT* yѣG $?mge bbbFFF͐BX3(**4dddtf kFy!{62@HЊ311ETTUZZ5CRRP&Iɚb_}ݑ#GȪ~ qƅ3KKK_|)--ӧdQnuqq9vXmmO? I2_UUUvtt$O獌<==444%%%w9vX]]ӧOYK_˗/_j9:i~aaaZZZ , vv޽WNIIh}ĨGGGs{ݾosu@EEb0Y}wzAUtquuuVVNt:Z[[X+8t"//hii+++pD~1ef|g)..[n:' O=z:H#bbbmV*<ӣ_ 铎΍7lllSY.IOO333Vs@Ս1cƔ)S$ƍuA|Wllllllg_O>6ly2B vA㞸#FN81QPP?|pA8'&8ȏpP }}?/΃ *jP+}$3\r#oP@ZZPs4) O '@VV.NRuN'&&Ԅ)}Na4r_sNk111ͮB5!~9\<h555 ½H'PTvaAk:ell:Eg(A΀񴌌 cc㠠 ng׮][hQEEN9.ܿ_\\u }n^Bpp Ĉ 155\L]]]LLВMMMqqb*<}87n,]JOeddPi '7oެ^x1 GHHήʼnǹs68::i޽{T\\:;h wwwAHdd$ ҥK=5S0HII 6 'ƣ^ZUU:...<5'ɓ'M&Kiiizz: ʼn!!u>3mڴjT^|EEEgΜٴi,|ZSS*8͗4mӦMH5 2cƌ[n!y Y~ҥK ȇ>|8w `|#?? x\&!!… ۷o޽{ IkNNNjjjk|*Jߣ֮]{޽~5Ǐdec0%@yd~/.N%%%V}QGdSS6O}7oƍ"R3gs@\%--MHS&y+Vs$ᑓП;)P޼yT >tw\\\_(rrr Q(e˖J%VXѣ~#9GqqqʤR2#;;;11SpT+,,,))1ydTرc o{)8د Ǐ1m*!""2}Sp>G왱1P####99vSp$''PTGGϟ<u"A3~x~;)8RSS=<<\VVu$""~.NQ^^^QQ/..:xm$9ΉLu5B]Bu5TW94Ʉ~ |Z^@Zyy޿YUUuƍ?[ !'ʠ///IJ?7tAZQV **/]]Ѐ/bhѢ7op{G8 7 =!7rr 7h jj 99JIk6YIImgG |^ jk*+--cc:pݻwg̘nbb챚x^4HMt}}\ ZZpz/ȿ@L LM ++BM>ۃ=a &:(ZdI|||zz:WC? , FF`k Y|^WxFpraEa:iҤ+++CHܹBI È0v,@IZ[!9 "Owws@kkoƽ(/{mFɓaX9GAh(Ł 3g0 Y&((~ d2#| aTXv}ÇŰp!HHNBLLsllȑ#Zp,\ `e|恊 X|܁!&`X,-Q_AN:ɓ\ǀD,ZD rrʕDL @b~BG F$Q_JJJ---\jgL 1a@A44$(ZZ ++'E~rsłc ivquuё!X"#aHOgg zS1 ?s怌HqʕF oYIى/F1r Xr帿zs> :}74HK M.EUTTN˖AL = KSGHHHHHHqqe7nzCC Jx|knn%#[ǝD# 999z+۷ow74tiiFCד] 믪555Vy™tv-uE>^E %icjjjnsmƮcǎ䴴/[lΜ9=oM066f2@|LmmmWW?d 7HLLܷo_aa!333 y֪U9s>g)))ŋE*9tPtttuuɓx<ٍAn.) mmmoߖf:Y<\nB144 ]p-lמ3**J̛7/ 7!33SЀUOOoƌ^^^***"899;W@hhxpEX,_ dg# <)Ӎѣ***RSIPwlr:uW ?""dN F䭱NܹS}*I82ĉTj^^${ }૯^8i$AGGUkĄNK544,,,m֭[TbD9믑>ey7o;TRJKAB(''f(Si ~o755:uŋޖ74mܸq555ug%#F$ MUDDDFFFAA:Tp|@RY }7M٫+|Aףٳg+vvv;Qp"?F>_UUUXX!SR%Gqq^qmٲEOOOpm(g8Bh{q8dWrі-JES2;.KC^iI8xik###HfSZ`ΞEVVH]mۆȮ$,+""b(=(B}Y `#pqXP_ }6 . a˖7w|E]tpzp<d¡C!Bh(LǏCWe) 6N)SE%,R-^7II l]?47Ch(twåK `aa1iҤ!]g+#a$3-]h4Co碡HIA7B**(, tڋ={h=\.B\]8O,\s”)og$ =z/ٳGCd$DF9e)j33#G,on8228*+ٳ!<ꂔx.^2000X|}ǐ?矃x,rr :3PU}=CV@L 2\A͛p&CC00c,X3f/ ݻ>8ͅ7 1n߆FсS<<ND Gp6@7LVUU5jԨG.Yd>HN4 lm&Nq`89*ÁlɁ!==n77ĉLgii9a„/Q8IHHx.^ 'i=Nv!<̝;֭[?---.]»7퀎lq8rqI6|͞=… 1I'O]&?sέIx><NTWWߺu+""B0NLr/_677wrr SL&qc N>x^pJ/zHÄ]|fGFF9!}555L&SGSNɮ*Lv]vM:!Tq8H<''g@|APdV P{{Ùx޽pJVff@{]<ÊoЖ-[&N8s̾pJsFFم`OHHS&Mva QTvQ(*lڴiҥ~~~bpD``~5~A<n,izUEuhz,YsN-q8eKOO/&&fp$WbOTT&wKʦ[no)4mǎۺuϾ TF/ef@)񨨨m۶رC-ee奥d)'N|k׮ݴi$q8e/$L!ڵkٲe˗/߽{ᔡ?\؛>X~M:$S?,<ߛ)777""+VcSB(// ǻyLmnne˖bivکSX344O?to/_|AmmYԤxSSSSxmww;wY,Щ</++...vvviiizjƚWNIIyֶÇl6Jzj\.޾ڵk T2l6;##޽{)))iiiRL\Z)$yggg^f͕+WJKK%s~AxO;;; [d/h4A3++A?ϟ?7n`ʖ-[}ǶĪo.R+((,Ӊ͛7 HXfhhx333HJJr)))NW^kddDldܹ?ǏE'9|~bb5k7b?ObbG֯_?==*&&HSSSp~ܹڼHLLD=|J:88?~PGGXJP.\xɪ^߉/FؼyǏ\2j(xIjaaaL&3$$dΝ=k#4Yxx)SёdjhhX,֢E똘!%IO>̙` g[[ȑ#l`a`` B嚙ikks\A3ވ˥RgڴiC»7o O>-؍1cy;w*___ؽ{`mTT=zT|y>5EXtלgƽpb,3g\rݺu۶m㏸&17g/^HJJjhhh,܌㩫?C}EEE^znnn tuuK\իW׭[)8ZWW'I'D8k޽+|dWy>5E}vWO@*'d 9mF6m`@d55cǖ_9s|ΝcƌZj՝;w̄XXXUWW zŤΝ;Į :::֬YsA3w-K^oܸ ,gϞ[9zhgjjjkkU^^.X{]gg={W{{*RÇܨTj'~ {M9 ~~~gΜ!ά|;~h~:>rf1ϹcJKK_ɬX!D܃:a„Wfgg>|`뗖"*++cˈ#Gܽ{7::zÆ 4->>S|'Ԕfωé(t޽{_khhlܸ[~:wwrA'iiis|s/X5}G s://7o )_4 <ߎbx葛[FFk77z3K;::|N_[;\h4I{#{{@===tnii)r :C|y⟚'0LApb0?s*fĭŧd4 ^XNŕ7 r"RQ^;IB_é(*0jbΝSϗmƍL&sÆ ^D8I0yA]]}#G꡴455ǎ3b&LPWW'&&& Ev e,q„ 涵988`>!c0_Ξ=@L'bȑ|r۩Tjyy/..&h4;;^', @0ޞF3 /x{Iggz``jW5jɇjjjS ܹsZZZׯ_O?hll$f#_v^[[رcϞ=w^@@ 닺ߞ={ 7X[[Z*;;ZxIZ8pdL4׶?d2ǍgggGO?9srr4^bbee5rH1!""BWWd38pƍ,+""Bpb$cǎ;w&''{xx|9< }F {MZjll,**4p8EEE<ߣwvv $/Hgʈ]X_;;;gkgN tttČ/B___4yTj*W^YYY>}Ux*aSZIII !Ĕ+U >!a ')(S5V+bC"0pQesIDAT*$ ]@Eq{Nť7p*R2L.w8_%`0EMr1&-=X ')(|)(N SP8p81LApb0Éa ')(N SP8p81LApb0Éa ')(N SP8p81LApb0Éa KQx<{IENDB`automat-25.4.16/docs/_static/mystate.machine.MyMachine._machine.dot.png000066400000000000000000000346241500000722400257170ustar00rootroot00000000000000PNG  IHDR,9*f]bKGD IDATxwXTW?PAHU jHDEq%617M1[d75e5&((1b" E"Ed \<Ýs?wtޜ{n9!~qFBW@!"!D4L.@jj|?*+r_Q |Z#}c#`LMR)`cX[ڿ?`o !'rrGNp.PPTU WDCpvaÚ:; W!@қiiq"ܾ{Bfj{T6H@߾Y!z&\\ $'ǏgR[6x088wzP.c}}.ky9PVw=Q.gWy&NNo7!D_~RRXZbKa[06+?2377{x6 :,!Dǁ%<]xY[[ri0H5kkyORGfjkLpwm" W={mۀ\ ]Pb'U.'OGuuʗ/^xgVe%oὈPk> -EO˘DEN6nxkB@zXĎk//[!w 駼ץ3g V!^`T6QYXhF8ª7[o͚ɀUW^ Bz.{X7ܓ&<  ৙| %!"y>aÀӧ)F"ềv5c[!i`-^̯qWW )UMgyh?LXtXZ~'ND"uze{b1o? W!ݡ4**T [Pd ? 1_BT43`|'uYхO?m=oDHڐsvf `Ą۷.Ylƍ,000kkk>VSSclڵ GKl…Ã3f'NZNr9ꫯؘ1c%ӧ=z4۴ijoujۧgetjBԱ]`;x,WbŊ#..1Zv KNNnZVr9[hQ˽KjmowԶJ֮:[-!jXqq́i///&Jپ}XEEa쭷b[nmZ.%%`k׮UNQQS4!b=P񟝜dAA(hʿǭ3g?;^Ys\\Ç!C|Wq6VF4ॗMaVD|Tk>o'Ǐ ˖MxKH噟u~("Dxĉ͛~ہ>57!G|E&$4ApDÆ9{N)N\{[yy|ߟoQKw ?>Pܡؼ_/)lhBG r!W^ồDӁXw^{hS\K1>pjj́(>)kT`fo;fsH% ,ӧ jx`~Tk_ee@B?71Q+G LK!7_kpn[[󱰙3HS\/HVe3{_Bs@߾^`)cZr@D?~$ۥNN~}[rܹNK$Dh)|Gɍ>}|?G&Yn.O5HKΞcS>Dgƌm`$W|T ~":x01{{> mm{g?Q?*+y/ Ͽ v0E;vv@h(0a?@D -m.^lutI3S|-㡤PU滸ͻ4[ !*齁 cr+3Xrxxٙ ּE'r @+*e%%@y68cЅ*+ݣG|yZHZ ၣصTf:;󞓋 +ڝ#D++9v&M3g $$Dr!~bg᱖3BDIK&~SO=D"t9:vJJJSO ] !D:  ] !D6d2ߏ ] !DC6N<̝;WR!tt=~̞=G2.]̞=[R!~X[[c„ BB `0/+zXHKKìY.azX 022ɓ.azXƘ1cП!DU`544_~AddХB@+%%BU`>| KzX SzXz*N*t)-ћ_ H0qDK!hֱc0zh 8PR!ZW5i$ h^VNNn߾pK!h^/ccc?^R!Zu1ẗ́LG 1\`ܸqBB2ӧ!!!WB6֩S {{{K!h^֘1c.GҥKXQ# ,B ̙3J9rХBt@ԁ] !DDX/_ѣ.# \~FB6]zab@DX/_D"!Du :VVVBB˗wEm`]~BA!QVcc#&t)e`塱#uP`b`DXo߆\\\.C [n BB!СC.c  ]!DDX߇eBtLU^^NbDXX Vcc#)1@ 0`kk+t)e`!HtUSSJWB5L& \ !DDXr`d$ !=$OD"B.xBtMtշo_@mmBtMt8:8ZH1 ,;;;TR!:&211AQTT$t)]`=!Hl (7n B]:q-<|鼬d3g+#D"##?Q2#VPPqEې刏G~~+#D{?K. ]D=z4,--q)˗PehOpp%N=,\Ξ~i@ȑ#M>|` .d kvaaa횘vtwuuu߿rin-Zam/+:l7..1ڵk;\Fm`W=j9((-YDcF,x0ڵ3L*Yfu1죏>bg2ǁeٱ͛7;wjv9?M*{WXVVe'N` KOOoZM<dzcy{{$v-k56oQFVVVƪYrr2cXjjZ輪Jl߾}԰t[o[6-k׶kCmQ`RFFYxx8irtkzRQɔ)SnӦM oZ=y`LLLZu0D[~J+<<w{MFFV\щp:]a[gis{z o5fiiɆxVZZFQ_]Ŋ+333Z l밵EDDʯ8qb>>ꫯ0j(kQ|`iˑsrr`ooӧ$ uxxx ??W^m;e+os߾}QQQmPvdQU&&& êU֭[BLLL2FFoU /^;vG5.\QF!11Q5KY}666Dtt4jjjtZfi 2͟?[5ƶkG;š{ۿ6ȑ#]Z6zh5,X`oՎ\.gn3vaOeb(˖-kZ999)= Am{P.o׏1]zU#AFFFسg'T*BBBeߪz'k.=ppp%Ksssoaacǎaɒ%4h郠 8p[[VH$غu+vލ ={6~nh̙3Xt)|}}aaa`رزe bcc366qA*jC"Hxb? ٳgIcLhd2 ` FaUVVPfkkΝ;uzX7|;wDnn.jkkz iii8q".tӧ~s ,ʍ7saСJ}`ii 6nzzz:z̞=[[C~+@*b޼yxH[P`VbccBԩS|8a铒,\ =yܿ쌣G6-D 3< .^D"O<?~<'O6nl?V^I& ΒVUU/_777bɒ%fjqFΘ1FFFpssúuZ݂ +++"..j,_C f̘.iƍpssT*qζר[xzzUUUՠ2,&=IDATetgg.3n &(VPPܹYTTc1''V1鹠 uVm1[[[v1VYY֯_ϬXIIIHg\]]ٙ3gXMM [n4h];k,6n8_|ncc#x"dvjucDzgyl{13gd999, dƍԔ8qձSNzmP:~Vmu8s .^5l#V[n5=wݽ{G;zn/`i6???'4 \eee)}wO?볲Zc%%%5=x ~/ɘ|r۔äReTynCt.Vqq)z!+yyy011СCl]OY  j⺲N۽}62w^˗/o0[NNNM766FUUn߾annnob _ĥK:\^mhIݺi066FII  .wiz͛MhV-^8;;@6kСdkCaa!r9bccNƫ zȐ!022BiiigΜi3gDbb"Ξ=I&!,, oUkzRdAwwwaҥ(,,D^^-[H888[nEEErssrvm888>7oFrr2jrԮxzzb̘1xp=wiU۬YLܹs3gN9r.n޼dZ۾ߪlCר[ 8UVX*ؽ{7,--1zh ۷o|G(--f̘s{oÇ¢Ѽ_~]vСC8p`6Ν;1j(L:pû|СCgx ؙݻw>}:,X033l߾>>>ĠAk!::ә'LDFFسg,--۽ߪl#u6KKKTWW ~ 7~&%???cP:t ]hН1٢EҶm5ڽ{7LLL0e`ʕxg.KڎVK'n߾lѽ]M޽{1d̘1&M'|z6 \3BSK moСZT[jn7oPz3aB#AUEE"Xư*Gb些W,cXX-Y2+!8{" ,ŋ..j*XY8 >֬Y5k]!* x(++5޶l`fBkRqq1cX]``%"">ô"Q`BDƭn7[Q`b0h  ]J(1pŢ(1xMvXLx{{ ]J(1`u.E%X444`ذaB ,B Xff&P`BzLHMKX,x{{C"]J(1`(1h׮]"~(,,D@@ХXŋХ@!(1X ,B PEEn߾-+!KKKT*](1@m["bܸqBmXlbرBmX'N@"n"_+W ++Kv@cBAQOrr2&N066L&///a Ԭ8a'd2۸y& |G{i!"l1^YYYxw 6<"D$I022°a/*"Dĺ ,###~ ,BDbaΝ>|*. ,BDH$x0gV]&B@Q_Gebb'b:HE) ,899a޽066*"DD" < ,BDm`1ưm69Rư'7O?-`EE=,BDLÒH$;v,>C+.aew@EE󣲒PX5ySSʪ @yyϊ+-0GEhѠK$=.ggaÀCht{BT!p \\CJ$FF+8988lm5WӥKp9,\TVee<$ yhyy{sC# 0ut\mZGEH_Μ^חx˫+ 0FWWVz2{{{ 8? ! ee@b"p(k  5 Gj5+7WZp,p$WJ1cH]\MXİ]\&BW{|Ge%194IME Oa!g};PZXcyCIZɀSx8\߷3\DE CCm#|\'E#vΝ͛!^K(~+/ش ,^ <? cc{7;xu'4: ,>,Z,_̉vO?G xX6McYs~$cb?e Iwnwd$?wN3"zoI *k {> m͔HpӁh`~Girɩ_ 0bwGED%.;vHaddd#>OR-WG0p!?1#5'׀ǎB"S_<4^ sS!֩S\Ϭ>x~m+"NiFE/: ,"*ojN`lDH$|:_w@B]"mq#|BH'ND"uze{bѷ/=3XDJK@LM8_lXD|߇Oh "ޭ[]?v% ذajkk֭WD"iztq,Z0771sL 0ccvm~w6c1ب)H7acf]^^^L*} VSS[oŶnڴ\JJ ֮]"6ydX}}=+..f{aREFFZoQFVVVƪYrr2cXjjZ۫,,::eeejvq6j(/Qmu:عsJMEz?[[>P7 g^^^ ,;<[`sttd&&&z.*Όݻw.##`+WTq [SVHH~LMMZmCLL+H^7xnySNƌnp+}]]medd@&巋$cMVwV}mM2; @WS|rݬׯ_憔L6 #F+WTngǚ5kpMA.1oon$me2d2YS;`{G(F2YՌE[ 7bbbpY[999ldeeͽo:k/^ݻwa&gmAff&<<.b͸z*PYYD!''iRRRPWWW`ӦMDee%cjbbbP[['p*mdQ9q5ƆW8dj]B":FF|pv૯o* ~;`(>ŰaVL p(QH Rf&ߕOLQtQ=?' ]iǏzSEEDOqCRp>'ZUe(= |eˀt`Xۤ"z#< &⽮ѣ4ƥM;..R%{]KH\:…՝SD#G;>}o GE ƩSz{Mƌ5k]PǎO<BC P`!l3P_Ϗ`̜͘ sHS=xg|0ݔ~@Q-$G\CbVRz$?--?쁁]_aX(d$'Ç5} 䏀]j5~tELg׮yz='L"#;P`ґGsxx>?̊0llxxy{a//ͭ^XZM䏬,~M~pO~D587UBEHwJKŇԔstG$詇J(,ZP'|7Օͽ{oMow犊<>V"xX(I$Ŝ |hѣmiy`シ{}h= ,BtrgtUU|, Էoe~`_. So it behooves me to say why yet another one would be a good idea. Automat is designed around the following principle: while organizing your code around state machines is a good idea, your callers don't, and shouldn't have to, care that you've done so. In Python, the "input" to a stateful system is a method call; the "output" may be a method call, if you need to invoke a side effect, or a return value, if you are just performing a computation in memory. Most other state-machine libraries require you to explicitly create an input object, provide that object to a generic "input" method, and then receive results, sometimes in terms of that library's interfaces and sometimes in terms of classes you define yourself. Therefore, from the outside, an Automat state machine looks like a Plain Old Python Object (POPO). It has methods, and the methods have type annotations, and you can call them and get their documented return values. automat-25.4.16/docs/conf.py000066400000000000000000000165511500000722400155700ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # automat documentation build configuration file, created by # sphinx-quickstart on Thu Sep 14 19:11:24 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys docs_dir = os.path.dirname(os.path.abspath(__file__)) automat_dir = os.path.dirname(docs_dir) sys.path.insert(0, automat_dir) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.intersphinx", "pydoctor.sphinx_ext.build_apidocs", "sphinx.ext.autosectionlabel", ] import pathlib import subprocess _project_root = pathlib.Path(__file__).parent.parent _source_root = _project_root / "src" _git_reference = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True, encoding="utf8", capture_output=True, check=True, ).stdout # Try to find URL fragment for the GitHub source page based on current # branch or tag. if _git_reference == "HEAD": # It looks like the branch has no name. # Fallback to commit ID. _git_reference = subprocess.getoutput("git rev-parse HEAD") if os.environ.get("READTHEDOCS", "") == "True": rtd_version = os.environ.get("READTHEDOCS_VERSION", "") if "." in rtd_version: # It looks like we have a tag build. _git_reference = rtd_version pydoctor_args = [ # pydoctor should not fail the sphinx build, we have another tox environment for that. "--intersphinx=https://docs.twisted.org/en/twisted-22.1.0/api/objects.inv", "--intersphinx=https://docs.python.org/3/objects.inv", "--intersphinx=https://graphviz.readthedocs.io/en/stable/objects.inv", "--intersphinx=https://zopeinterface.readthedocs.io/en/latest/objects.inv", # TODO: not sure why I have to specify these all twice. f"--config={_project_root}/.pydoctor.cfg", f"--html-viewsource-base=https://github.com/glyph/automat/tree/{_git_reference}/src", f"--project-base-dir={_source_root}", "--html-output={outdir}/api", "--privacy=HIDDEN:automat.test.*", "--privacy=HIDDEN:automat.test", "--privacy=HIDDEN:**.__post_init__", str(_source_root / "automat"), ] pydoctor_url_path = "/en/{rtd_version}/api/" intersphinx_mapping = { "py3": ("https://docs.python.org/3", None), "zopeinterface": ("https://zopeinterface.readthedocs.io/en/latest", None), "twisted": ("https://docs.twisted.org/en/twisted-22.1.0/api", None), } # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. project = "automat" copyright = "2017, Glyph" author = "Glyph" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. def _get_release() -> str: import importlib.metadata try: return importlib.metadata.version(project) except importlib.metadata.PackageNotFoundError: raise Exception("You must install Automat to build the documentation.") # The full version, including alpha/beta/rc tags. release = _get_release() # The short X.Y version. version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { "**": [ "about.html", "navigation.html", "relations.html", # needs 'show_related': True theme option to display "searchbox.html", "donate.html", ] } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = "automatdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements: dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "automat.tex", "automat Documentation", "Glyph", "manual"), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "automat", "automat Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "automat", "automat Documentation", author, "automat", "One line description of project.", "Miscellaneous", ), ] automat-25.4.16/docs/examples/000077500000000000000000000000001500000722400160775ustar00rootroot00000000000000automat-25.4.16/docs/examples/automat_card.py000066400000000000000000000054211500000722400211160ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Protocol from automat import TypeMachineBuilder @dataclass class PaymentBackend: accounts: dict[str, int] = field(default_factory=dict) def checkBalance(self, accountID: str) -> int: "how many AutoBux™ do you have" return self.accounts[accountID] def deduct(self, accountID: str, amount: int) -> None: "deduct some amount of money from the given account" balance = self.accounts[accountID] newBalance = balance - amount if newBalance < 0: raise ValueError("not enough money") self.accounts[accountID] = newBalance @dataclass class Food: name: str price: int doorNumber: int class Doors: def openDoor(self, number: int) -> None: print(f"opening door {number}") class Automat(Protocol): def swipeCard(self, accountID: str) -> None: "Swipe a payment card with the given account ID." def selectFood(self, doorNumber: int) -> None: "Select a food." def _dispenseFood(self, doorNumber: int) -> None: "Open a door and dispense the food." @dataclass class AutomatCore: payments: PaymentBackend foods: dict[int, Food] # mapping door-number to food doors: Doors @dataclass class PaymentDetails: accountID: str def rememberAccount( inputs: Automat, core: AutomatCore, accountID: str ) -> PaymentDetails: print(f"remembering {accountID=}") return PaymentDetails(accountID) # define machine builder = TypeMachineBuilder(Automat, AutomatCore) idle = builder.state("idle") choosing = builder.state("choosing", rememberAccount) idle.upon(Automat.swipeCard).to(choosing).returns(None) # end define @choosing.upon(Automat.selectFood).loop() def selected( inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int ) -> None: food = core.foods[doorNumber] try: core.payments.deduct(details.accountID, core.foods[doorNumber].price) except ValueError as ve: print(ve) else: inputs._dispenseFood(doorNumber) @choosing.upon(Automat._dispenseFood).to(idle) def doOpen( inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int ) -> None: core.doors.openDoor(doorNumber) machineFactory = builder.build() if __name__ == "__main__": machine = machineFactory( AutomatCore( PaymentBackend({"alice": 100}), { 1: Food("burger", 5, 1), 2: Food("fries", 3, 2), 3: Food("pheasant under glass", 200, 3), }, Doors(), ) ) machine.swipeCard("alice") print("too expensive") machine.selectFood(3) print("just right") machine.selectFood(1) print("oops") machine.selectFood(2) automat-25.4.16/docs/examples/automat_example.py000066400000000000000000000065001500000722400216370ustar00rootroot00000000000000 from automat import MethodicalMachine class Door(object): def unlock(self): print("Opening the door so you can get your food.") def lock(self): print("Locking the door so you can't steal the food.") class Light(object): def on(self): print("Need some food over here.") def off(self): print("We're good on food for now.") class FoodSlot(object): """ Automats were a popular kind of business in the 1950s and 60s; a sort of restaurant-sized vending machine that served cooked food out of a coin-operated dispenser. This class represents the logic associated with a single food slot. """ machine = MethodicalMachine() def __init__(self, door, light): self._door = door self._light = light self.start() @machine.state(initial=True) def initial(self): """ The initial state when we are constructed. Note that applications never see this state, because the constructor provides an input to transition out of it immediately. """ @machine.state() def empty(self): """ The machine is empty (and the light asking for food is on). """ @machine.input() def start(self): """ A private input, for transitioning to the initial blank state to 'empty', making sure the door and light are properly configured. """ @machine.state() def ready(self): """ We've got some food and we're ready to serve it. """ @machine.state() def serving(self): """ The door is open, we're serving food. """ @machine.input() def coin(self): """ A coin (of the appropriate denomination) was inserted. """ @machine.input() def food(self): """ Food was prepared and inserted into the back of the machine. """ @machine.output() def turnOnFoodLight(self): """ Turn on the 'we need food' light. """ self._light.on() @machine.output() def turnOffFoodLight(self): """ Turn off the 'we need food' light. """ self._light.off() @machine.output() def lockDoor(self): """ Lock the door, we don't need food. """ self._door.lock() @machine.output() def unlockDoor(self): """ Unock the door, it's chow time!. """ self._door.unlock() @machine.input() def closeDoor(self): """ The door was closed. """ initial.upon(start, enter=empty, outputs=[lockDoor, turnOnFoodLight]) empty.upon(food, enter=ready, outputs=[turnOffFoodLight]) ready.upon(coin, enter=serving, outputs=[unlockDoor]) serving.upon(closeDoor, enter=empty, outputs=[lockDoor, turnOnFoodLight]) slot = FoodSlot(Door(), Light()) if __name__ == '__main__': import sys sys.stdout.writelines(FoodSlot.machine.asDigraph()) # raw_input("Hit enter to make some food and put it in the slot: ") # slot.food() # raw_input("Hit enter to insert a coin: ") # slot.coin() # raw_input("Hit enter to retrieve the food and close the door: ") # slot.closeDoor() # raw_input("Hit enter to make some more food: ") # slot.food() automat-25.4.16/docs/examples/coffee_expanded.py000066400000000000000000000114171500000722400215540ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Callable, Protocol from automat import TypeMachineBuilder @dataclass class Beans: description: str @dataclass class Water: "It's Water" @dataclass class Carafe: "It's a carafe" full: bool = False @dataclass class Ready: beans: Beans water: Water carafe: Carafe def brew(self) -> Mixture: print(f"brewing {self.beans} with {self.water} in {self.carafe}") return Mixture(self.beans, self.water) @dataclass class Mixture: beans: Beans water: Water class Brewer(Protocol): def brew_button(self) -> None: "The user pressed the 'brew' button." def wait_a_while(self) -> Mixture: "Allow some time to pass." def put_in_beans(self, beans: Beans) -> None: "The user put in some beans." def put_in_water(self, water: Water) -> None: "The user put in some water." def put_in_carafe(self, carafe: Carafe) -> None: "The user put the mug" class _BrewerInternals(Brewer, Protocol): def _ready(self, beans: Beans, water: Water, carafe: Carafe) -> None: "We are ready with all of our inputs." @dataclass class Light: on: bool = False @dataclass class BrewCore: "state for the brew process" ready_light: Light brew_light: Light beans: Beans | None = None water: Water | None = None carafe: Carafe | None = None brewing: Mixture | None = None def _coffee_machine() -> TypeMachineBuilder[_BrewerInternals, BrewCore]: """ Best practice: these functions are all fed in to the builder, they don't need to call each other, so they don't need to be defined globally. Use a function scope to avoid littering a module with states and such. """ builder = TypeMachineBuilder(_BrewerInternals, BrewCore) # reveal_type(builder) not_ready = builder.state("not_ready") def ready_factory( brewer: _BrewerInternals, core: BrewCore, beans: Beans, water: Water, carafe: Carafe, ) -> Ready: return Ready(beans, water, carafe) def mixture_factory(brewer: _BrewerInternals, core: BrewCore) -> Mixture: # We already do have a 'ready' but it's State-Specific Data which makes # it really annoying to relay on to the *next* state without passing it # through the state core. requiring the factory to take SSD inherently # means that it could only work with transitions away from a single # state, which would not be helpful, although that *is* what we want # here. assert core.beans is not None assert core.water is not None assert core.carafe is not None return Mixture(core.beans, core.water) ready = builder.state("ready", ready_factory) brewing = builder.state("brewing", mixture_factory) def ready_check(brewer: _BrewerInternals, core: BrewCore) -> None: if ( core.beans is not None and core.water is not None and core.carafe is not None and core.carafe.full is not None ): brewer._ready(core.beans, core.water, core.carafe) @not_ready.upon(Brewer.put_in_beans).loop() def put_beans(brewer: _BrewerInternals, core: BrewCore, beans: Beans) -> None: core.beans = beans ready_check(brewer, core) @not_ready.upon(Brewer.put_in_water).loop() def put_water(brewer: _BrewerInternals, core: BrewCore, water: Water) -> None: core.water = water ready_check(brewer, core) @not_ready.upon(Brewer.put_in_carafe).loop() def put_carafe(brewer: _BrewerInternals, core: BrewCore, carafe: Carafe) -> None: core.carafe = carafe ready_check(brewer, core) @not_ready.upon(_BrewerInternals._ready).to(ready) def get_ready( brewer: _BrewerInternals, core: BrewCore, beans: Beans, water: Water, carafe: Carafe, ) -> None: print("ready output") @ready.upon(Brewer.brew_button).to(brewing) def brew(brewer: _BrewerInternals, core: BrewCore, ready: Ready) -> None: core.brew_light.on = True print("BREW CALLED") core.brewing = ready.brew() @brewing.upon(_BrewerInternals.wait_a_while).to(not_ready) def brewed(brewer: _BrewerInternals, core: BrewCore, mixture: Mixture) -> Mixture: core.brew_light.on = False return mixture return builder CoffeeMachine: Callable[[BrewCore], Brewer] = _coffee_machine().build() if __name__ == "__main__": machine = CoffeeMachine(core := BrewCore(Light(), Light())) machine.put_in_beans(Beans("light roast")) machine.put_in_water(Water()) machine.put_in_carafe(Carafe()) machine.brew_button() brewed = machine.wait_a_while() print(brewed) automat-25.4.16/docs/examples/dont_get_state.py000066400000000000000000000015471500000722400214630ustar00rootroot00000000000000from dataclasses import dataclass from typing import Protocol from automat import TypeMachineBuilder class Transport: def send(self, arg: bytes) -> None: print(f"sent: {arg!r}") # begin salient class Connector(Protocol): def sendMessage(self) -> None: "send a message" @dataclass class Core: _transport: Transport builder = TypeMachineBuilder(Connector, Core) disconnected = builder.state("disconnected") connected = builder.state("connector") @connected.upon(Connector.sendMessage).loop() def actuallySend(connector: Connector, core: Core) -> None: core._transport.send(b"message") @disconnected.upon(Connector.sendMessage).loop() def failSend(connector: Connector, core: Core): print("not connected") # end salient machineFactory = builder.build() machine = machineFactory(Core(Transport())) machine.sendMessage() automat-25.4.16/docs/examples/feedback_debugging.py000066400000000000000000000011741500000722400222130ustar00rootroot00000000000000import dataclasses import typing from automat import TypeMachineBuilder class Inputs(typing.Protocol): def behavior1(self) -> None: ... def behavior2(self) -> None: ... class Nothing: ... builder = TypeMachineBuilder(Inputs, Nothing) start = builder.state("start") @start.upon(Inputs.behavior1).loop() def one(inputs: Inputs, core: Nothing) -> None: print("starting behavior 1") inputs.behavior2() print("ending behavior 1") @start.upon(Inputs.behavior2).loop() def two(inputs: Inputs, core: Nothing) -> None: print("behavior 2") machineFactory = builder.build() machineFactory(Nothing()).behavior1() automat-25.4.16/docs/examples/feedback_errors.py000066400000000000000000000011111500000722400215630ustar00rootroot00000000000000import dataclasses import typing from automat import TypeMachineBuilder #begin class Inputs(typing.Protocol): def compute(self) -> int: ... def behavior(self) -> None: ... class Nothing: ... builder = TypeMachineBuilder(Inputs, Nothing) start = builder.state("start") @start.upon(Inputs.compute).loop() def three(inputs: Inputs, core: Nothing) -> int: return 3 @start.upon(Inputs.behavior).loop() def behave(inputs: Inputs, core: Nothing) -> None: print("computed:", inputs.compute()) #end machineFactory = builder.build() machineFactory(Nothing()).behavior() automat-25.4.16/docs/examples/feedback_order.py000066400000000000000000000012231500000722400213660ustar00rootroot00000000000000import dataclasses import typing from automat import TypeMachineBuilder class Inputs(typing.Protocol): def compute(self) -> int: ... def behavior(self) -> None: ... class Nothing: ... builder = TypeMachineBuilder(Inputs, Nothing) start = builder.state("start") @start.upon(Inputs.compute).loop() def three(inputs: Inputs, core: Nothing) -> int: return 3 # begin computations computations = [] @start.upon(Inputs.behavior).loop() def behave(inputs: Inputs, core: Nothing) -> None: computations.append(inputs.compute) machineFactory = builder.build() machineFactory(Nothing()).behavior() print(computations[0]()) # end computations automat-25.4.16/docs/examples/garage_door.py000066400000000000000000000055261500000722400207320ustar00rootroot00000000000000import dataclasses import typing from enum import Enum, auto from automat import NoTransition, TypeMachineBuilder class Direction(Enum): up = auto() stopped = auto() down = auto() @dataclasses.dataclass class Motor: direction: Direction = Direction.stopped def up(self) -> None: assert self.direction is Direction.stopped self.direction = Direction.up print("motor running up") def stop(self) -> None: self.direction = Direction.stopped print("motor stopped") def down(self) -> None: assert self.direction is Direction.stopped self.direction = Direction.down print("motor running down") @dataclasses.dataclass class Alarm: def beep(self) -> None: "Sound an alarm so that the user can hear." print("beep beep beep") # protocol definition class GarageController(typing.Protocol): def pushButton(self) -> None: "Push the button to open or close the door" def openSensor(self) -> None: "The 'open' sensor activated; the door is fully open." def closeSensor(self) -> None: "The 'close' sensor activated; the door is fully closed." # end protocol definition # core definition @dataclasses.dataclass class DoorDevices: motor: Motor alarm: Alarm "end core definition" # end core definition # start building builder = TypeMachineBuilder(GarageController, DoorDevices) # build states closed = builder.state("closed") opening = builder.state("opening") opened = builder.state("opened") closing = builder.state("closing") # end states # build methods @closed.upon(GarageController.pushButton).to(opening) def startOpening(controller: GarageController, devices: DoorDevices) -> None: devices.motor.up() @opening.upon(GarageController.openSensor).to(opened) def finishedOpening(controller: GarageController, devices: DoorDevices): devices.motor.stop() @opened.upon(GarageController.pushButton).to(closing) def startClosing(controller: GarageController, devices: DoorDevices) -> None: devices.alarm.beep() devices.motor.down() @closing.upon(GarageController.closeSensor).to(closed) def finishedClosing(controller: GarageController, devices: DoorDevices): devices.motor.stop() # end methods # do build machineFactory = builder.build() # end building # story if __name__ == "__main__": # do instantiate machine = machineFactory(DoorDevices(Motor(), Alarm())) # end instantiate print("pushing button...") # do open machine.pushButton() # end open print("pushedW") try: machine.pushButton() except NoTransition: print("this is not implemented yet") print("triggering open sensor, pushing button again") # sensor and close machine.openSensor() machine.pushButton() # end close print("pushed") machine.closeSensor() # end story automat-25.4.16/docs/examples/garage_door_security.py000066400000000000000000000057331500000722400226610ustar00rootroot00000000000000import dataclasses import typing from enum import Enum, auto from automat import NoTransition, TypeMachineBuilder class Direction(Enum): up = auto() stopped = auto() down = auto() @dataclasses.dataclass class Motor: direction: Direction = Direction.stopped def up(self) -> None: assert self.direction is Direction.stopped self.direction = Direction.up print("motor running up") def stop(self) -> None: self.direction = Direction.stopped print("motor stopped") def down(self) -> None: assert self.direction is Direction.stopped self.direction = Direction.down print("motor running down") @dataclasses.dataclass class Alarm: def beep(self) -> None: "Sound an alarm so that the user can hear." print("beep beep beep") # protocol definition class GarageController(typing.Protocol): def pushButton(self, remoteID: str) -> None: "Push the button to open or close the door" def openSensor(self) -> None: "The 'open' sensor activated; the door is fully open." def closeSensor(self) -> None: "The 'close' sensor activated; the door is fully closed." # end protocol definition # core definition @dataclasses.dataclass class DoorDevices: motor: Motor alarm: Alarm "end core definition" # end core definition # start building builder = TypeMachineBuilder(GarageController, DoorDevices) # build states closed = builder.state("closed") opening = builder.state("opening") opened = builder.state("opened") closing = builder.state("closing") # end states # build methods @closed.upon(GarageController.pushButton).to(opening) def startOpening(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: print(f"opened by {remoteID}") devices.motor.up() @opening.upon(GarageController.openSensor).to(opened) def finishedOpening(controller: GarageController, devices: DoorDevices): devices.motor.stop() @opened.upon(GarageController.pushButton).to(closing) def startClosing(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: print(f"closed by {remoteID}") devices.alarm.beep() devices.motor.down() @closing.upon(GarageController.closeSensor).to(closed) def finishedClosing(controller: GarageController, devices: DoorDevices): devices.motor.stop() # end methods # do build machineFactory = builder.build() # end building # story if __name__ == "__main__": # do instantiate machine = machineFactory(DoorDevices(Motor(), Alarm())) # end instantiate print("pushing button...") # do open machine.pushButton("alice") # end open print("pushed") try: machine.pushButton("bob") except NoTransition: print("this is not implemented yet") print("triggering open sensor, pushing button again") # sensor and close machine.openSensor() machine.pushButton("carol") # end close print("pushed") machine.closeSensor() # end story automat-25.4.16/docs/examples/io_coffee_example.py000066400000000000000000000024411500000722400221030ustar00rootroot00000000000000from automat import MethodicalMachine class CoffeeBrewer(object): _machine = MethodicalMachine() @_machine.input() def brew_button(self): "The user pressed the 'brew' button." @_machine.output() def _heat_the_heating_element(self): "Heat up the heating element, which should cause coffee to happen." # self._heating_element.turn_on() @_machine.state() def have_beans(self): "In this state, you have some beans." @_machine.state(initial=True) def dont_have_beans(self): "In this state, you don't have any beans." @_machine.input() def put_in_beans(self, beans): "The user put in some beans." @_machine.output() def _save_beans(self, beans): "The beans are now in the machine; save them." self._beans = beans @_machine.output() def _describe_coffee(self): return "A cup of coffee made with {}.".format(self._beans) dont_have_beans.upon(put_in_beans, enter=have_beans, outputs=[_save_beans]) have_beans.upon( brew_button, enter=dont_have_beans, outputs=[_heat_the_heating_element, _describe_coffee], collector=lambda iterable: list(iterable)[-1], ) cb = CoffeeBrewer() cb.put_in_beans("real good beans") print(cb.brew_button()) automat-25.4.16/docs/examples/lightswitch.py000066400000000000000000000024741500000722400210110ustar00rootroot00000000000000from operator import itemgetter from automat import MethodicalMachine class LightSwitch(object): machine = MethodicalMachine() @machine.state(serialized="on") def on_state(self): "the switch is on" @machine.state(serialized="off", initial=True) def off_state(self): "the switch is off" @machine.input() def flip(self): "flip the switch" on_state.upon(flip, enter=off_state, outputs=[]) off_state.upon(flip, enter=on_state, outputs=[]) @machine.input() def query_power(self): "return True if powered, False otherwise" @machine.output() def _is_powered(self): return True @machine.output() def _not_powered(self): return False on_state.upon( query_power, enter=on_state, outputs=[_is_powered], collector=itemgetter(0) ) off_state.upon( query_power, enter=off_state, outputs=[_not_powered], collector=itemgetter(0) ) @machine.serializer() def save(self, state): return {"is-it-on": state} @machine.unserializer() def _restore(self, blob): return blob["is-it-on"] @classmethod def from_blob(cls, blob): self = cls() self._restore(blob) return self if __name__ == "__main__": l = LightSwitch() print(l.query_power()) automat-25.4.16/docs/examples/serialize_machine.py000066400000000000000000000036521500000722400221320ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Protocol, Self from automat import TypeMachineBuilder @dataclass class Core: value: int @dataclass class DataObj: datum: str @classmethod def create(cls, inputs: Inputs, core: Core, datum: str) -> Self: return cls(datum) # begin salient class Inputs(Protocol): def serialize(self) -> tuple[int, str | None]: ... def next(self) -> None: ... def data(self, datum: str) -> None: ... builder = TypeMachineBuilder(Inputs, Core) start = builder.state("start") nodata = builder.state("nodata") data = builder.state("data", DataObj.create) nodata.upon(Inputs.data).to(data).returns(None) start.upon(Inputs.next).to(nodata).returns(None) @nodata.upon(Inputs.serialize).loop() def serialize(inputs: Inputs, core: Core) -> tuple[int, None]: return (core.value, None) @data.upon(Inputs.serialize).loop() def serializeData(inputs: Inputs, core: Core, data: DataObj) -> tuple[int, str]: return (core.value, data.datum) # end salient # build and serialize machineFactory = builder.build() machine = machineFactory(Core(3)) machine.next() print(machine.serialize()) machine.data("hi") print(machine.serialize()) # end build def deserializeWithoutData(serialization: tuple[int, DataObj | None]) -> Inputs: coreValue, dataValue = serialization assert dataValue is None, "not handling data yet" return machineFactory(Core(coreValue), nodata) print(deserializeWithoutData((3, None))) def deserialize(serialization: tuple[int, str | None]) -> Inputs: coreValue, dataValue = serialization if dataValue is None: return machineFactory(Core(coreValue), nodata) else: return machineFactory( Core(coreValue), data, lambda inputs, core: DataObj(dataValue), ) print(deserialize((3, None)).serialize()) print(deserialize((4, "hello")).serialize()) automat-25.4.16/docs/examples/turnstile_example.py000066400000000000000000000025241500000722400222200ustar00rootroot00000000000000from automat import MethodicalMachine class Lock(object): "A sample I/O device." def engage(self): print("Locked.") def disengage(self): print("Unlocked.") class Turnstile(object): machine = MethodicalMachine() def __init__(self, lock): self.lock = lock @machine.input() def arm_turned(self): "The arm was turned." @machine.input() def fare_paid(self): "The fare was paid." @machine.output() def _engage_lock(self): self.lock.engage() @machine.output() def _disengage_lock(self): self.lock.disengage() @machine.output() def _nope(self): print("**Clunk!** The turnstile doesn't move.") @machine.state(initial=True) def _locked(self): "The turnstile is locked." @machine.state() def _unlocked(self): "The turnstile is unlocked." _locked.upon(fare_paid, enter=_unlocked, outputs=[_disengage_lock]) _unlocked.upon(arm_turned, enter=_locked, outputs=[_engage_lock]) _locked.upon(arm_turned, enter=_locked, outputs=[_nope]) turner = Turnstile(Lock()) print("Paying fare 1.") turner.fare_paid() print("Walking through.") turner.arm_turned() print("Jumping.") turner.arm_turned() print("Paying fare 2.") turner.fare_paid() print("Walking through 2.") turner.arm_turned() print("Done.") automat-25.4.16/docs/examples/turnstile_typified.py000066400000000000000000000024621500000722400224030ustar00rootroot00000000000000from typing import Callable, Protocol from automat import TypeMachineBuilder class Lock: "A sample I/O device." def engage(self) -> None: print("Locked.") def disengage(self) -> None: print("Unlocked.") class Turnstile(Protocol): def arm_turned(self) -> None: "The arm was turned." def fare_paid(self, coin: int) -> None: "The fare was paid." def buildMachine() -> Callable[[Lock], Turnstile]: builder = TypeMachineBuilder(Turnstile, Lock) locked = builder.state("Locked") unlocked = builder.state("Unlocked") @locked.upon(Turnstile.fare_paid).to(unlocked) def pay(self: Turnstile, lock: Lock, coin: int) -> None: lock.disengage() @locked.upon(Turnstile.arm_turned).loop() def block(self: Turnstile, lock: Lock) -> None: print("**Clunk!** The turnstile doesn't move.") @unlocked.upon(Turnstile.arm_turned).to(locked) def turn(self: Turnstile, lock: Lock) -> None: lock.engage() return builder.build() TurnstileImpl = buildMachine() turner = TurnstileImpl(Lock()) print("Paying fare 1.") turner.fare_paid(1) print("Walking through.") turner.arm_turned() print("Jumping.") turner.arm_turned() print("Paying fare 2.") turner.fare_paid(1) print("Walking through 2.") turner.arm_turned() print("Done.") automat-25.4.16/docs/index.rst000066400000000000000000000045451500000722400161320ustar00rootroot00000000000000========================================================================= Automat: Self-service finite-state machines for the programmer on the go. ========================================================================= .. image:: https://upload.wikimedia.org/wikipedia/commons/d/db/Automat.jpg :width: 250 :align: right Automat is a library for concise, idiomatic Python expression of finite-state automata (particularly `deterministic finite-state transducers `_). .. _Garage-Example: Why use state machines? ======================= Sometimes you have to create an object whose behavior varies with its state, but still wishes to present a consistent interface to its callers. For example, let's say we are writing the software for a garage door controller. The garage door is composed of 4 components: 1. A motor which can be run up or down, to raise or lower the door respectively. 2. A sensor that activates when the door is fully open. 3. A sensor that activates when the door is fully closed. 4. A button that tells the door to open or close. It's very important that the garage door does not get confused about its state, because we could burn out the motor if we attempt to close an already-closed door or open an already-open door. With diligence and attention to detail, you can implement this correctly using a collection of attributes on an object; ``isOpen``, ``isClosed``, ``motorRunningDirection``, and so on. However, you have to keep all these attributes consistent. As the software becomes more complex - perhaps you want to add a safety sensor that prevents the door from closing when someone is standing under it, for example - they all potentially need to be updated, and any invariants about their mutual interdependencies. Rather than adding tedious ``if`` checks to every method on your ``GarageDoor`` to make sure that all internal state is consistent, you can use a state machine to ensure that if your code runs at all, it will be run with all the required values initialized, because they have to be called in the order you declare them. You can read more about state machines and their advantages for Python programmers `in an excellent article by J.P. Calderone. `_ .. toctree:: :maxdepth: 2 :caption: Contents: tutorial compare visualize api/index automat-25.4.16/docs/requirements.in000066400000000000000000000000411500000722400173270ustar00rootroot00000000000000sphinx pydoctor sphinx_rtd_theme automat-25.4.16/docs/requirements.txt000066400000000000000000000040151500000722400175450ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --no-emit-index-url # alabaster==0.7.16 # via sphinx appdirs==1.4.4 # via pydoctor attrs==24.2.0 # via # automat # pydoctor # twisted automat==22.10.0 # via twisted babel==2.16.0 # via sphinx cachecontrol[filecache]==0.14.0 # via # cachecontrol # pydoctor certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests configargparse==1.7 # via pydoctor constantly==23.10.4 # via twisted docutils==0.20.1 # via # pydoctor # sphinx # sphinx-rtd-theme filelock==3.15.4 # via cachecontrol hyperlink==21.0.0 # via twisted idna==3.7 # via # hyperlink # requests imagesize==1.4.1 # via sphinx incremental==24.7.2 # via twisted jinja2==3.1.6 # via sphinx lunr==0.6.2 # via pydoctor markupsafe==2.1.5 # via jinja2 msgpack==1.0.8 # via cachecontrol packaging==24.1 # via sphinx pydoctor==24.3.3 # via -r requirements.in pygments==2.18.0 # via sphinx requests==2.32.3 # via # cachecontrol # pydoctor # sphinx six==1.16.0 # via automat snowballstemmer==2.2.0 # via sphinx sphinx==7.4.7 # via # -r requirements.in # sphinx-rtd-theme # sphinxcontrib-jquery sphinx-rtd-theme==2.0.0 # via -r requirements.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jquery==4.1 # via sphinx-rtd-theme sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx toml==0.10.2 # via pydoctor twisted==24.7.0 # via pydoctor typing-extensions==4.12.2 # via twisted urllib3==2.2.2 # via # pydoctor # requests zope-interface==7.0.1 # via twisted # The following packages are considered to be unsafe in a requirements file: # setuptools automat-25.4.16/docs/tutorial.rst000066400000000000000000000510211500000722400166550ustar00rootroot00000000000000******** Tutorial ******** .. note:: Automat 24.8 is a *major* change to the public API - effectively a whole new library. For ease of migration, the code and API documentation still contains ``MethodicalMachine``, effectively the previous version of the library. However, for readability, the narrative documentation now *only* documents ``TypeMachineBuilder``. If you need documentation for that earlier version, you can find it as v22.10.0 on readthedocs. The Basics: a Garage Door Opener ================================ Describing the State Machine ---------------------------- Let's consider :ref:`the garage door example from the introduction`. Automat takes great care to present a state machine as a collection of regular methods. So we define what those methods *are* with a :py:class:`typing.Protocol` that describes them. .. literalinclude:: examples/garage_door.py :pyobject: GarageController This protocol tells us that only 3 things can happen to our controller from the outside world (its inputs): the user can push the button, the "door is all the way up" sensor can emit a signal, or the "door is all the way down" sensor can emit a signal. So those are our inputs. However, our state machine also needs to be able to *affect* things in the world (its outputs). As we are writing a program in Python, these come in the form of a Python object that can be shared between all the states that implement our controller, and for this purpose we define a simple shared-data class: .. literalinclude:: examples/garage_door.py :pyobject: DoorDevices Here we have a reference to a ``Motor`` that can open and close the door, and an ``Alarm`` that can beep to alert people that the door is closing. Next we need to combine those together, using a :py:class:`automat.TypeMachineBuilder`. .. literalinclude:: examples/garage_door.py :start-after: start building :end-before: build states Next we have to define our states. Let's start with four simple ones: 1. closed - the door is closed and idle 2. opening - the door is actively opening 3. opened - the door is open and idle 4. closing - the door is actively closing .. literalinclude:: examples/garage_door.py :start-after: build states :end-before: end states To describe the state machine, we define a series of transitions, using the method ``.upon()``: .. literalinclude:: examples/garage_door.py :start-after: build methods :end-before: end methods Building and using the state machine ------------------------------------ Now that we have described all the inputs, states, and output behaviors, it's time to actually build the state machine: .. literalinclude:: examples/garage_door.py :start-after: do build :end-before: end building The :py:meth:`automat.TypeMachineBuilder.build` method creates a callable that takes an instance of its state core (``DoorDevices``) and returns an object that conforms to its inputs protocol (``GarageController``). We can then take this ``machineFactory`` and call it, like so: .. literalinclude:: examples/garage_door.py :start-after: do instantiate :end-before: end instantiate Because we defined ``closed`` as our first state above, the machine begins in that state by default. So the first thing we'll do is to open the door: .. literalinclude:: examples/garage_door.py :start-after: do open :end-before: end open If we run this, we will then see some output, indicating that the motor is running: .. code-block:: motor running up If we press the button again, rather than silently double-starting the motor, we will get an error, since we haven't yet defined a state transition for this state yet. The traceback looks like this: .. code-block:: Traceback (most recent call last): File "", line 1, in machine.pushButton() File ".../automat/_typed.py", line 419, in implementation [outputs, tracer] = transitioner.transition(methodInput) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../automat/_core.py", line 196, in transition outState, outputSymbols = self._automaton.outputForInput( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../automat/_core.py", line 169, in outputForInput raise NoTransition(state=inState, symbol=inputSymbol) automat._core.NoTransition: no transition for pushButton in TypedState(name='opening') At first, this might seem like it's making more work for you. If you don't want to crash the code that calls your methods, you need to provide many more implementations of the same method for each different state. But, in this case, by causing this exception *before* running any of your code, Automat is protecting your internal state: although client code will get an exception, the *internal* state of your garage door controller will remain consistent. If you did not explicitly take a specific state into consideration while implementing some behavior, that behavior will never be invoked. Therefore, it cannot do something potentially harmful like double-starting the motor. If we trigger the open sensor so that the door completes its transition to the 'open' state, then push the button again, the buzzer will sound and the door will descend: .. literalinclude:: examples/garage_door.py :start-after: sensor and close :end-before: end close .. code-block:: motor stopped beep beep beep motor running down Try these exercises to get to to know Automat a little bit better: - When the button is pushed while the door is opening, the motor should stop, and if it's pressed again, the door should go in the reverse direction; for exmaple, if it's opening, it should pause and then close again, and if it's closing, it should pause and then open again. Make it do this rather than raise an exception. - Add a 'safety sensor' input, that refuses to close the door while it is tripped. Taking, Storing, and Returning Data ----------------------------------- Any method defined by the input protocol can take arguments and return values, just like any Python method. In order to facilitate this, all transition behavior methods must be able to accept any signature that their input can. To demonstrate this, let's add a feature to our door. Instead of a single button, let's add the ability to pair multiple remotes to open the door, so we can note which remote was used in a security log. For starters, we will need to modify our ``pushButton`` method to accept a ``remoteID`` argument, which we can print out. .. literalinclude:: examples/garage_door_security.py :pyobject: GarageController.pushButton If you're using ``mypy``, you will immediately see a type error when making this change, as all the calls to ``.upon(GarageController.pushButton)`` now complain something like this: .. code-block:: garage_door_security.py:75:2: error: Argument 1 to "__call__" of "TransitionRegistrar" has incompatible type "Callable[[GarageController, DoorDevices], None]"; expected "Callable[[GarageController, DoorDevices, str], None]" [arg-type] The ``TransitionRegistrar`` object is the result of calling ``.to(...)``, so what this is saying is that your function that is decorated with, say, ``@closed.upon(GarageController.pushButton).to(opening)``, takes your input protocol and your shared core object (as all transition behavior functions must), but does *not* take the ``str`` argument that ``pushButton`` takes. To fix it, we can add that parameter everywhere, and print it out, like so: .. literalinclude:: examples/garage_door_security.py :pyobject: startOpening Obviously, mypy will also complain that our test callers are missing the ``remoteID`` argument as well, so if we change them to pass along some value like so: .. literalinclude:: examples/garage_door.py :start-after: do open :end-before: end open Then we will see it in our output: .. code-block:: opened by alice Return values are treated in the same way as parameters. If your input protocol specifies a return type, then all behavior methods must also return that type. Your type checker will help ensure that these all line up for you as well. You can download the full examples here: - :download:`examples/garage_door.py` - :download:`examples/garage_door_security.py` More Advanced Usage: a Membership Card Automat Restaurant ========================================================= Setting Up the Example ---------------------- We will have to shift to a slightly more complex example to demonstrate Automat's more sophisticated features. Rather than opening the single door on our garage, let's implement the payment machine for an Automat - a food vending machine. Our automat operates on a membership system. You buy an AutoBux card, load it up, and then once you are at the machine, you swipe your card, make a selection, your account is debited, and your food is dispensed. State-specific Data ------------------- One of the coolest feature of Automat is not merely enforcing state transitions, but ensuring that the right data is always available in the right state. For our membership-card example, will start in an "idle" state, but when a customer swipes their card and starts to make their food selection, we have now entered the "choosing" state, it is crucial that *if we are in the choosing state, then we* **must** *know which customer's card we will charge*. We set up the state machine in much the same way as before: a state core: .. literalinclude:: examples/automat_card.py :pyobject: AutomatCore And an inputs protocol: .. literalinclude:: examples/automat_card.py :pyobject: Automat It may jump out at you that the ``_dispenseFood`` method is private. That's a bit unusual for a ``Protocol``, which is usually used to describe a publicly-facing API. Indeed, you might even want a *second* ``Protocol`` to hide this away from your public documentation. But for Automat, this is important because it's what lets us implement a *conditional state transition*, something commonly associated with state-specific data. We will get to that in a moment, but first, let's define that data. We'll begin with a function that, like transition behavior functions, takes our input protocol and core type. Its job will be to build our state-specific data for the "choosing" state, i.e. payment details. Entering this state requires an ``accountID`` as supplied by our ``swipeCard`` input, so we will require that as a parameter as well: .. literalinclude:: examples/automat_card.py :pyobject: rememberAccount Next, let's actually build the machine. We will use ``rememberAccount`` as the second parameter to ``TypeMachineBuilder.state()``, which defines ``choosing`` as a data state: .. literalinclude:: examples/automat_card.py :start-after: define machine :end-before: end define .. note:: Here, because swipeCard doesn't need any behavior and returns a static, immutable type (None), we define the transition with ``.returns(None)`` rather than giving it a behavior function. This is the same as using ``@idle.upon(Automat.swipeCard).to(choosing)`` as a decorator on an empty function, but a lot faster to type and to read. The fact that ``choosing`` is a data state adds two new requirements to its transitions:x 1. First, for every transition defined *to* the ``choosing`` state, the data factory function -- ``rememberAccount`` -- must be callable with whatever parameters defined in the input. If you want to make a lenient data factory that supports multiple signatures, you can always add ``*args: object, **kwargs: object`` to its signature, but any parameters it requires (in this case, ``accountID``) *must* be present in any input protocol methods that transition *to* ``choosing`` so that they can be passed along to the factory. 2. Second, for every transition defined *from* the ``choosing`` state, behavior functions will accept an additional parameter, of the same type returned by their state-specific data factory function. In other words, we will build a ``PaymentDetails`` object on every transition *to* ``choosing``, and then remember and pass that object to every behavior function as long as the machine remains in that state. Conditional State Transitions ----------------------------- Formally, in a deterministic finite-state automaton, an input in one state must result in the same transition to the same output state. When you define transitions statically, Automat adheres to this rule. However, in many real-world cases, which state you end up in after a particular event depends on things like the input data or internal state. In this example, if the user's AutoBux™ account balance is too low, then the food should not be dispensed; it should prompt the user to make another selection. Because it must be static, this means that the transition we will define from the ``choosing`` state upon ``selectFood`` will actually be a ``.loop()`` -- in other words, back to ``choosing`` -- rather than ``.to(idle)``. Within the behavior function of that transition, if we have determined that the user's card has been charged properly, we will call *back* into the ``Automat`` protocol via the ``_dispenseFood`` private input, like so: .. literalinclude:: examples/automat_card.py :pyobject: selected And since we want *that* input to transition us back to ``idle`` once the food has been dispensed, once again, we register a static transition, and this one's behavior is much simpler: .. literalinclude:: examples/automat_card.py :pyobject: doOpen You can download the full example here: - :download:`examples/garage_door_security.py` Reentrancy ---------- Observant readers may have noticed a slightly odd detail in the previous section. If our ``selected`` behavior function can cause a transition to another state before it's completed, but that other state's behaviors may require invariants that are maintained by previous behavior (i.e. ``selected`` itself) having completed, doesn't that create a paradox? How can we just invoke ``inputs._dispenseFood`` and have it work? In fact, you can't. This is an unresolvable paradox, and automat does a little trick to allow this convenient illusion, but it only works in some cases. Problems that lend themselves to state machines often involve setting up state to generate inputs back to the state machine in the future. For example, in the garage door example above, we implicitly registered sensors to call the ``openSensor`` and ``closeSensor`` methods. A more complete implementation in the behavior might need to set a timeout with an event loop, to automatically close the door after a certain amount of time. Being able to treat the state machines inputs as regular bound methods that can be used in callbacks is extremely convenient for this sort of thing. For those use cases, there are no particular limits on what can be called; once the behavior itself is finished and it's no longer on the stack, the object will behave exactly as its ``Protocol`` describes. One constraint is that any method you invoke in this way cannot return any value except None. This very simple machine, for example, that attempts to invoke a behavior that returns an integer: .. literalinclude:: examples/feedback_errors.py :start-after: #begin :end-before: #end will result in a traceback like so: .. code-block:: File "feedback_errors.py", line 24, in behave print("computed:", inputs.compute()) ^^^^^^^^^^^^^^^^ File ".../automat/_typed.py", line 406, in implementation raise RuntimeError( RuntimeError: attempting to reentrantly run Inputs.compute but it wants to return not None However, if instead of calling the method *immediately*, we save the method away to invoke later, it works fine once the current behavior function has completed: .. literalinclude:: examples/feedback_order.py :start-after: begin computations :end-before: end computations This simply prints ``3``, as expected. But why is there a constraint on return type? Surely a ``None``-returning method with side effects depends on its internal state just as much as something that returns a value? Running it re-entrantly before finishing the previous behavior would leave things in an invalid state, so how can it run at all? The magic that makes this work is that Automat automatically makes the invocation *not reentrant*, by re-ordering it for you. It can *re-order a second behavior that returns None to run at the end of your current behavior*, but it cannot steal a return value from the future, so it raises an exception to avoid confusion. But there is still the potentially confusing edge-case of re-ordering. A machine that contains these two behaviors: .. literalinclude:: examples/feedback_debugging.py :pyobject: one .. literalinclude:: examples/feedback_debugging.py :pyobject: two will, when ``.behavior1()`` is invoked on it, print like so: .. code-block:: starting behavior 1 ending behavior 1 behavior 2 In general, this re-ordering *is* what you want idiomatically when working with a state machine, but it is important to know that it can happen. If you have code that you do want to invoke side effects in a precise order, put it in a function or into a method on your shared core. How do I get the current state of a state machine? ================================================== Don't do that. One major reason for having a state machine is that you want the callers of the state machine to just provide the appropriate input to the machine at the appropriate time, and *not have to check themselves* what state the machine is in. The *whole point* of Automat is to never, ever write code that looks like this, and places the burden on the caller: .. code-block:: python if connectionMachine.state == "CONNECTED": connectionMachine.sendMessage() else: print("not connected") Instead, just make your calling code do this: .. code-block:: python connectionMachine.sendMessage() and then change your state machine to look like this: .. literalinclude:: examples/dont_get_state.py :start-after: begin salient :end-before: end salient so that the responsibility for knowing which state the state machine is in remains within the state machine itself. If I can't get the state of the state machine, how can I save it to (a database, an API response, a file on disk...) ==================================================================================================================== On the serialization side, you can build inputs that return a type that every state can respond to. For example, here's a machine that maintains an ``int`` value in its core, and a ``str`` value in a piece of state-specific data. This really just works like implementing any other return value. .. literalinclude:: examples/serialize_machine.py :start-after: begin salient :end-before: end salient getting the data out then looks like this: .. literalinclude:: examples/serialize_machine.py :start-after: build and serialize :end-before: end build which produces: .. code-block:: (3, None) (3, DataObj(datum='hi')) Future versions of automat may include some utility functionaity here to reduce boilerplate, but no additional features are required to address this half of the problem. However, for *de*serialization, we do need the ability to start in a different initial state. For non-data states, it's simple enough; construct an appropriate shared core, and just pass the state that you want; in our case, ``nodata``: .. literalinclude:: examples/serialize_machine.py :pyobject: deserializeWithoutData Finally, all we need to deserialize a state with state-specific data is to pass a factory function which takes ``inputs, core`` as arguments, just like behavior and data-factory functions. Since we are skipping *directly* to the data state, we will skip the data factory declared on the state itself, and call this one: .. literalinclude:: examples/serialize_machine.py :pyobject: deserialize .. note:: In this specific deserialization context, since the object isn't even really constructed yet, the ``inputs`` argument is in a *totally* invalid state and cannot be invoked reentrantly at all; any method will raise an exception if called during the duration of this special deserialization data factory. You can only use it to save it away on your state-specific data for future invocations once the state machine instance is built. You can download the full example here: - :download:`examples/serialize_machine.py` And that's pretty much all you need to know in order to build type-safe state machines with Automat! automat-25.4.16/docs/visualize.rst000066400000000000000000000057141500000722400170350ustar00rootroot00000000000000================ Visualizations ================ Installation ============ To create state machine graphs you must install `automat` with the graphing dependencies. .. code-block:: bash pip install automat[visualize] To generate images, you will also need to install `Graphviz `_ for your platform, such as with ``brew install graphviz`` on macOS or ``apt install graphviz`` on Ubuntu. Example ======= If we put the garage door example from the tutorial into a file called ``garage_door.py``, You can generate a state machine visualization by running: .. code-block:: bash $ automat-visualize garage_door garage_door.machineFactory ...discovered garage_door.machineFactory ...wrote image and dot into .automat_visualize The `dot` file and `png` will be saved in the default output directory, to the file ``.automat_visualize/garage_door.machineFactory.dot.png`` . .. image:: _static/garage_door.machineFactory.dot.png :alt: garage door state machine ``automat-visualize`` help ========================== .. code-block:: bash $ automat-visualize -h usage: /home/tom/Envs/tmp-72fe664d2dc5cbf/bin/automat-visualize [-h] [--quiet] [--dot-directory DOT_DIRECTORY] [--image-directory IMAGE_DIRECTORY] [--image-type {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot}] [--view] fqpn Visualize automat.MethodicalMachines as graphviz graphs. positional arguments: fqpn A Fully Qualified Path name representing where to find machines. optional arguments: -h, --help show this help message and exit --quiet, -q suppress output --dot-directory DOT_DIRECTORY, -d DOT_DIRECTORY Where to write out .dot files. --image-directory IMAGE_DIRECTORY, -i IMAGE_DIRECTORY Where to write out image files. --image-type {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot}, -t {gv,vml,dot_json,imap_np,pov,tiff,pic,canon,jpg,ismap,sgi,webp,gd,json0,ps2,cmapx_np,plain-ext,wbmp,xdot_json,ps,cgimage,ico,gtk,pct,gif,json,fig,xlib,xdot1.2,tif,tk,xdot1.4,svgz,gd2,jpe,psd,xdot,bmp,jpeg,x11,cmapx,jp2,imap,png,tga,pict,plain,eps,vmlz,cmap,exr,svg,pdf,vrml,dot} The image format. --view, -v View rendered graphs with default image viewer You must have the graphviz tool suite installed. Please visit http://www.graphviz.org for more information. automat-25.4.16/mypy.ini000066400000000000000000000002731500000722400150320ustar00rootroot00000000000000[mypy] show_error_codes = True warn_unused_ignores = true no_implicit_optional = true strict_optional = true disallow_any_generics = true [mypy-graphviz.*] ignore_missing_imports = True automat-25.4.16/pyproject.toml000066400000000000000000000026061500000722400162510ustar00rootroot00000000000000[build-system] requires = [ "setuptools >= 35.0.2", "wheel >= 0.29.0", "setuptools-scm", "hatch-vcs", ] build-backend = "setuptools.build_meta" [project] name="Automat" dynamic = ["version"] authors=[ { name = "Glyph", email = "code@glyph.im" }, ] description="Self-service finite-state machines for the programmer on the go." readme="README.md" requires-python=">= 3.9" dependencies=[ 'typing_extensions; python_version<"3.10"', ] license={file="LICENSE"} keywords=[ "fsm", "state machine", "automata", ] classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Typing :: Typed", ] [project.urls] Documentation = "https://automat.readthedocs.io/" Source = "https://github.com/glyph/automat/" [project.optional-dependencies] visualize=[ "graphviz>0.5.1", "Twisted>=16.1.1", ] [project.scripts] automat-visualize = "automat._visualize:tool" [tool.setuptools.packages.find] where = ["src"] [tool.setuptools_scm] # No configuration required, but the section needs to exist? [tool.hatch] version.source = "vcs" automat-25.4.16/src/000077500000000000000000000000001500000722400141205ustar00rootroot00000000000000automat-25.4.16/src/automat/000077500000000000000000000000001500000722400155725ustar00rootroot00000000000000automat-25.4.16/src/automat/__init__.py000066400000000000000000000005441500000722400177060ustar00rootroot00000000000000# -*- test-case-name: automat -*- """ State-machines. """ from ._typed import TypeMachineBuilder, pep614, AlreadyBuiltError, TypeMachine from ._core import NoTransition from ._methodical import MethodicalMachine __all__ = [ "TypeMachineBuilder", "TypeMachine", "NoTransition", "AlreadyBuiltError", "pep614", "MethodicalMachine", ] automat-25.4.16/src/automat/_core.py000066400000000000000000000150071500000722400172360ustar00rootroot00000000000000# -*- test-case-name: automat._test.test_core -*- """ A core state-machine abstraction. Perhaps something that could be replaced with or integrated into machinist. """ from __future__ import annotations import sys from itertools import chain from typing import Callable, Generic, Optional, Sequence, TypeVar, Hashable if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias _NO_STATE = "" State = TypeVar("State", bound=Hashable) Input = TypeVar("Input", bound=Hashable) Output = TypeVar("Output", bound=Hashable) class NoTransition(Exception, Generic[State, Input]): """ A finite state machine in C{state} has no transition for C{symbol}. @ivar state: See C{state} init parameter. @ivar symbol: See C{symbol} init parameter. """ def __init__(self, state: State, symbol: Input): """ Construct a L{NoTransition}. @param state: the finite state machine's state at the time of the illegal transition. @param symbol: the input symbol for which no transition exists. """ self.state = state self.symbol = symbol super(Exception, self).__init__( "no transition for {} in {}".format(symbol, state) ) class Automaton(Generic[State, Input, Output]): """ A declaration of a finite state machine. Note that this is not the machine itself; it is immutable. """ def __init__(self, initial: State | None = None) -> None: """ Initialize the set of transitions and the initial state. """ if initial is None: initial = _NO_STATE # type:ignore[assignment] assert initial is not None self._initialState: State = initial self._transitions: set[tuple[State, Input, State, Sequence[Output]]] = set() self._unhandledTransition: Optional[tuple[State, Sequence[Output]]] = None @property def initialState(self) -> State: """ Return this automaton's initial state. """ return self._initialState @initialState.setter def initialState(self, state: State) -> None: """ Set this automaton's initial state. Raises a ValueError if this automaton already has an initial state. """ if self._initialState is not _NO_STATE: raise ValueError( "initial state already set to {}".format(self._initialState) ) self._initialState = state def addTransition( self, inState: State, inputSymbol: Input, outState: State, outputSymbols: tuple[Output, ...], ): """ Add the given transition to the outputSymbol. Raise ValueError if there is already a transition with the same inState and inputSymbol. """ # keeping self._transitions in a flat list makes addTransition # O(n^2), but state machines don't tend to have hundreds of # transitions. for anInState, anInputSymbol, anOutState, _ in self._transitions: if anInState == inState and anInputSymbol == inputSymbol: raise ValueError( "already have transition from {} to {} via {}".format( inState, anOutState, inputSymbol ) ) self._transitions.add((inState, inputSymbol, outState, tuple(outputSymbols))) def unhandledTransition( self, outState: State, outputSymbols: Sequence[Output] ) -> None: """ All unhandled transitions will be handled by transitioning to the given error state and error-handling output symbols. """ self._unhandledTransition = (outState, tuple(outputSymbols)) def allTransitions(self) -> frozenset[tuple[State, Input, State, Sequence[Output]]]: """ All transitions. """ return frozenset(self._transitions) def inputAlphabet(self) -> set[Input]: """ The full set of symbols acceptable to this automaton. """ return { inputSymbol for (inState, inputSymbol, outState, outputSymbol) in self._transitions } def outputAlphabet(self) -> set[Output]: """ The full set of symbols which can be produced by this automaton. """ return set( chain.from_iterable( outputSymbols for (inState, inputSymbol, outState, outputSymbols) in self._transitions ) ) def states(self) -> frozenset[State]: """ All valid states; "Q" in the mathematical description of a state machine. """ return frozenset( chain.from_iterable( (inState, outState) for (inState, inputSymbol, outState, outputSymbol) in self._transitions ) ) def outputForInput( self, inState: State, inputSymbol: Input ) -> tuple[State, Sequence[Output]]: """ A 2-tuple of (outState, outputSymbols) for inputSymbol. """ for anInState, anInputSymbol, outState, outputSymbols in self._transitions: if (inState, inputSymbol) == (anInState, anInputSymbol): return (outState, list(outputSymbols)) if self._unhandledTransition is None: raise NoTransition(state=inState, symbol=inputSymbol) return self._unhandledTransition OutputTracer = Callable[[Output], None] Tracer: TypeAlias = "Callable[[State, Input, State], OutputTracer[Output] | None]" class Transitioner(Generic[State, Input, Output]): """ The combination of a current state and an L{Automaton}. """ def __init__(self, automaton: Automaton[State, Input, Output], initialState: State): self._automaton: Automaton[State, Input, Output] = automaton self._state: State = initialState self._tracer: Tracer[State, Input, Output] | None = None def setTrace(self, tracer: Tracer[State, Input, Output] | None) -> None: self._tracer = tracer def transition( self, inputSymbol: Input ) -> tuple[Sequence[Output], OutputTracer[Output] | None]: """ Transition between states, returning any outputs. """ outState, outputSymbols = self._automaton.outputForInput( self._state, inputSymbol ) outTracer = None if self._tracer: outTracer = self._tracer(self._state, inputSymbol, outState) self._state = outState return (outputSymbols, outTracer) automat-25.4.16/src/automat/_discover.py000066400000000000000000000121151500000722400201210ustar00rootroot00000000000000from __future__ import annotations import collections import inspect from typing import Any, Iterator from twisted.python.modules import PythonAttribute, PythonModule, getModule from automat import MethodicalMachine from ._typed import TypeMachine, InputProtocol, Core def isOriginalLocation(attr: PythonAttribute | PythonModule) -> bool: """ Attempt to discover if this appearance of a PythonAttribute representing a class refers to the module where that class was defined. """ sourceModule = inspect.getmodule(attr.load()) if sourceModule is None: return False currentModule = attr while not isinstance(currentModule, PythonModule): currentModule = currentModule.onObject return currentModule.name == sourceModule.__name__ def findMachinesViaWrapper( within: PythonModule | PythonAttribute, ) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: """ Recursively yield L{MethodicalMachine}s and their FQPNs within a L{PythonModule} or a L{twisted.python.modules.PythonAttribute} wrapper object. Note that L{PythonModule}s may refer to packages, as well. The discovery heuristic considers L{MethodicalMachine} instances that are module-level attributes or class-level attributes accessible from module scope. Machines inside nested classes will be discovered, but those returned from functions or methods will not be. @type within: L{PythonModule} or L{twisted.python.modules.PythonAttribute} @param within: Where to start the search. @return: a generator which yields FQPN, L{MethodicalMachine} pairs. """ queue = collections.deque([within]) visited: set[ PythonModule | PythonAttribute | MethodicalMachine | TypeMachine[InputProtocol, Core] | type[Any] ] = set() while queue: attr = queue.pop() value = attr.load() if ( isinstance(value, MethodicalMachine) or isinstance(value, TypeMachine) ) and value not in visited: visited.add(value) yield attr.name, value elif ( inspect.isclass(value) and isOriginalLocation(attr) and value not in visited ): visited.add(value) queue.extendleft(attr.iterAttributes()) elif isinstance(attr, PythonModule) and value not in visited: visited.add(value) queue.extendleft(attr.iterAttributes()) queue.extendleft(attr.iterModules()) class InvalidFQPN(Exception): """ The given FQPN was not a dot-separated list of Python objects. """ class NoModule(InvalidFQPN): """ A prefix of the FQPN was not an importable module or package. """ class NoObject(InvalidFQPN): """ A suffix of the FQPN was not an accessible object """ def wrapFQPN(fqpn: str) -> PythonModule | PythonAttribute: """ Given an FQPN, retrieve the object via the global Python module namespace and wrap it with a L{PythonModule} or a L{twisted.python.modules.PythonAttribute}. """ # largely cribbed from t.p.reflect.namedAny if not fqpn: raise InvalidFQPN("FQPN was empty") components = collections.deque(fqpn.split(".")) if "" in components: raise InvalidFQPN( "name must be a string giving a '.'-separated list of Python " "identifiers, not %r" % (fqpn,) ) component = components.popleft() try: module = getModule(component) except KeyError: raise NoModule(component) # find the bottom-most module while components: component = components.popleft() try: module = module[component] except KeyError: components.appendleft(component) break else: module.load() else: return module # find the bottom-most attribute attribute = module for component in components: try: attribute = next( child for child in attribute.iterAttributes() if child.name.rsplit(".", 1)[-1] == component ) except StopIteration: raise NoObject("{}.{}".format(attribute.name, component)) return attribute def findMachines( fqpn: str, ) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: """ Recursively yield L{MethodicalMachine}s and their FQPNs in and under the a Python object specified by an FQPN. The discovery heuristic considers L{MethodicalMachine} instances that are module-level attributes or class-level attributes accessible from module scope. Machines inside nested classes will be discovered, but those returned from functions or methods will not be. @param fqpn: a fully-qualified Python identifier (i.e. the dotted identifier of an object defined at module or class scope, including the package and modele names); where to start the search. @return: a generator which yields (C{FQPN}, L{MethodicalMachine}) pairs. """ return findMachinesViaWrapper(wrapFQPN(fqpn)) automat-25.4.16/src/automat/_introspection.py000066400000000000000000000026411500000722400212060ustar00rootroot00000000000000""" Python introspection helpers. """ from types import CodeType as code, FunctionType as function def copycode(template, changes): if hasattr(code, "replace"): return template.replace(**{"co_" + k: v for k, v in changes.items()}) names = [ "argcount", "nlocals", "stacksize", "flags", "code", "consts", "names", "varnames", "filename", "name", "firstlineno", "lnotab", "freevars", "cellvars", ] if hasattr(code, "co_kwonlyargcount"): names.insert(1, "kwonlyargcount") if hasattr(code, "co_posonlyargcount"): # PEP 570 added "positional only arguments" names.insert(1, "posonlyargcount") values = [changes.get(name, getattr(template, "co_" + name)) for name in names] return code(*values) def copyfunction(template, funcchanges, codechanges): names = [ "globals", "name", "defaults", "closure", ] values = [ funcchanges.get(name, getattr(template, "__" + name + "__")) for name in names ] return function(copycode(template.__code__, codechanges), *values) def preserveName(f): """ Preserve the name of the given function on the decorated function. """ def decorator(decorated): return copyfunction(decorated, dict(name=f.__name__), dict(name=f.__name__)) return decorator automat-25.4.16/src/automat/_methodical.py000066400000000000000000000420731500000722400204220ustar00rootroot00000000000000# -*- test-case-name: automat._test.test_methodical -*- from __future__ import annotations import collections import sys from dataclasses import dataclass, field from functools import wraps from inspect import getfullargspec as getArgsSpec from itertools import count from typing import Any, Callable, Hashable, Iterable, TypeVar if sys.version_info < (3, 10): from typing_extensions import TypeAlias else: from typing import TypeAlias from ._core import Automaton, OutputTracer, Tracer, Transitioner from ._introspection import preserveName ArgSpec = collections.namedtuple( "ArgSpec", [ "args", "varargs", "varkw", "defaults", "kwonlyargs", "kwonlydefaults", "annotations", ], ) def _getArgSpec(func): """ Normalize inspect.ArgSpec across python versions and convert mutable attributes to immutable types. :param Callable func: A function. :return: The function's ArgSpec. :rtype: ArgSpec """ spec = getArgsSpec(func) return ArgSpec( args=tuple(spec.args), varargs=spec.varargs, varkw=spec.varkw, defaults=spec.defaults if spec.defaults else (), kwonlyargs=tuple(spec.kwonlyargs), kwonlydefaults=( tuple(spec.kwonlydefaults.items()) if spec.kwonlydefaults else () ), annotations=tuple(spec.annotations.items()), ) def _getArgNames(spec): """ Get the name of all arguments defined in a function signature. The name of * and ** arguments is normalized to "*args" and "**kwargs". Return type annotations are omitted, since we don't constrain input methods to have the same return type as output methods, nor output methods to have the same output type. :param ArgSpec spec: A function to interrogate for a signature. :return: The set of all argument names in `func`s signature. :rtype: Set[str] """ return set( spec.args + spec.kwonlyargs + (("*args",) if spec.varargs else ()) + (("**kwargs",) if spec.varkw else ()) + tuple(a for a in spec.annotations if a[0] != "return") ) def _keywords_only(f): """ Decorate a function so all its arguments must be passed by keyword. A useful utility for decorators that take arguments so that they don't accidentally get passed the thing they're decorating as their first argument. Only works for methods right now. """ @wraps(f) def g(self, **kw): return f(self, **kw) return g @dataclass(frozen=True) class MethodicalState(object): """ A state for a L{MethodicalMachine}. """ machine: MethodicalMachine = field(repr=False) method: Callable[..., Any] = field() serialized: bool = field(repr=False) def upon( self, input: MethodicalInput, enter: MethodicalState | None = None, outputs: Iterable[MethodicalOutput] | None = None, collector: Callable[[Iterable[T]], object] = list, ) -> None: """ Declare a state transition within the L{MethodicalMachine} associated with this L{MethodicalState}: upon the receipt of the `input`, enter the `state`, emitting each output in `outputs`. @param input: The input triggering a state transition. @param enter: The resulting state. @param outputs: The outputs to be triggered as a result of the declared state transition. @param collector: The function to be used when collecting output return values. @raises TypeError: if any of the `outputs` signatures do not match the `inputs` signature. @raises ValueError: if the state transition from `self` via `input` has already been defined. """ if enter is None: enter = self if outputs is None: outputs = [] inputArgs = _getArgNames(input.argSpec) for output in outputs: outputArgs = _getArgNames(output.argSpec) if not outputArgs.issubset(inputArgs): raise TypeError( "method {input} signature {inputSignature} " "does not match output {output} " "signature {outputSignature}".format( input=input.method.__name__, output=output.method.__name__, inputSignature=getArgsSpec(input.method), outputSignature=getArgsSpec(output.method), ) ) self.machine._oneTransition(self, input, enter, outputs, collector) def _name(self) -> str: return self.method.__name__ def _transitionerFromInstance( oself: object, symbol: str, automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput], ) -> Transitioner[MethodicalState, MethodicalInput, MethodicalOutput]: """ Get a L{Transitioner} """ transitioner = getattr(oself, symbol, None) if transitioner is None: transitioner = Transitioner( automaton, automaton.initialState, ) setattr(oself, symbol, transitioner) return transitioner def _empty(): pass def _docstring(): """docstring""" def assertNoCode(f: Callable[..., Any]) -> None: # The function body must be empty, i.e. "pass" or "return None", which # both yield the same bytecode: LOAD_CONST (None), RETURN_VALUE. We also # accept functions with only a docstring, which yields slightly different # bytecode, because the "None" is put in a different constant slot. # Unfortunately, this does not catch function bodies that return a # constant value, e.g. "return 1", because their code is identical to a # "return None". They differ in the contents of their constant table, but # checking that would require us to parse the bytecode, find the index # being returned, then making sure the table has a None at that index. if f.__code__.co_code not in (_empty.__code__.co_code, _docstring.__code__.co_code): raise ValueError("function body must be empty") def _filterArgs(args, kwargs, inputSpec, outputSpec): """ Filter out arguments that were passed to input that output won't accept. :param tuple args: The *args that input received. :param dict kwargs: The **kwargs that input received. :param ArgSpec inputSpec: The input's arg spec. :param ArgSpec outputSpec: The output's arg spec. :return: The args and kwargs that output will accept. :rtype: Tuple[tuple, dict] """ named_args = tuple(zip(inputSpec.args[1:], args)) if outputSpec.varargs: # Only return all args if the output accepts *args. return_args = args else: # Filter out arguments that don't appear # in the output's method signature. return_args = [v for n, v in named_args if n in outputSpec.args] # Get any of input's default arguments that were not passed. passed_arg_names = tuple(kwargs) for name, value in named_args: passed_arg_names += (name, value) defaults = zip(inputSpec.args[::-1], inputSpec.defaults[::-1]) full_kwargs = {n: v for n, v in defaults if n not in passed_arg_names} full_kwargs.update(kwargs) if outputSpec.varkw: # Only pass all kwargs if the output method accepts **kwargs. return_kwargs = full_kwargs else: # Filter out names that the output method does not accept. all_accepted_names = outputSpec.args[1:] + outputSpec.kwonlyargs return_kwargs = { n: v for n, v in full_kwargs.items() if n in all_accepted_names } return return_args, return_kwargs T = TypeVar("T") R = TypeVar("R") @dataclass(eq=False) class MethodicalInput(object): """ An input for a L{MethodicalMachine}. """ automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( repr=False ) method: Callable[..., Any] = field() symbol: str = field(repr=False) collectors: dict[MethodicalState, Callable[[Iterable[T]], R]] = field( default_factory=dict, repr=False ) argSpec: ArgSpec = field(init=False, repr=False) def __post_init__(self) -> None: self.argSpec = _getArgSpec(self.method) assertNoCode(self.method) def __get__(self, oself: object, type: None = None) -> object: """ Return a function that takes no arguments and returns values returned by output functions produced by the given L{MethodicalInput} in C{oself}'s current state. """ transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton) @preserveName(self.method) @wraps(self.method) def doInput(*args: object, **kwargs: object) -> object: self.method(oself, *args, **kwargs) previousState = transitioner._state (outputs, outTracer) = transitioner.transition(self) collector = self.collectors[previousState] values = [] for output in outputs: if outTracer is not None: outTracer(output) a, k = _filterArgs(args, kwargs, self.argSpec, output.argSpec) value = output(oself, *a, **k) values.append(value) return collector(values) return doInput def _name(self) -> str: return self.method.__name__ @dataclass(frozen=True) class MethodicalOutput(object): """ An output for a L{MethodicalMachine}. """ machine: MethodicalMachine = field(repr=False) method: Callable[..., Any] argSpec: ArgSpec = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: self.__dict__["argSpec"] = _getArgSpec(self.method) def __get__(self, oself, type=None): """ Outputs are private, so raise an exception when we attempt to get one. """ raise AttributeError( "{cls}.{method} is a state-machine output method; " "to produce this output, call an input method instead.".format( cls=type.__name__, method=self.method.__name__ ) ) def __call__(self, oself, *args, **kwargs): """ Call the underlying method. """ return self.method(oself, *args, **kwargs) def _name(self) -> str: return self.method.__name__ StringOutputTracer = Callable[[str], None] StringTracer: TypeAlias = "Callable[[str, str, str], StringOutputTracer | None]" def wrapTracer( wrapped: StringTracer | None, ) -> Tracer[MethodicalState, MethodicalInput, MethodicalOutput] | None: if wrapped is None: return None def tracer( state: MethodicalState, input: MethodicalInput, output: MethodicalState, ) -> OutputTracer[MethodicalOutput] | None: result = wrapped(state._name(), input._name(), output._name()) if result is not None: return lambda out: result(out._name()) return None return tracer @dataclass(eq=False) class MethodicalTracer(object): automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( repr=False ) symbol: str = field(repr=False) def __get__( self, oself: object, type: object = None ) -> Callable[[StringTracer], None]: transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton) def setTrace(tracer: StringTracer | None) -> None: transitioner.setTrace(wrapTracer(tracer)) return setTrace counter = count() def gensym(): """ Create a unique Python identifier. """ return "_symbol_" + str(next(counter)) class MethodicalMachine(object): """ A L{MethodicalMachine} is an interface to an L{Automaton} that uses methods on a class. """ def __init__(self): self._automaton = Automaton() self._reducers = {} self._symbol = gensym() def __get__(self, oself, type=None): """ L{MethodicalMachine} is an implementation detail for setting up class-level state; applications should never need to access it on an instance. """ if oself is not None: raise AttributeError("MethodicalMachine is an implementation detail.") return self @_keywords_only def state( self, initial: bool = False, terminal: bool = False, serialized: Hashable = None ): """ Declare a state, possibly an initial state or a terminal state. This is a decorator for methods, but it will modify the method so as not to be callable any more. @param initial: is this state the initial state? Only one state on this L{automat.MethodicalMachine} may be an initial state; more than one is an error. @param terminal: Is this state a terminal state? i.e. a state that the machine can end up in? (This is purely informational at this point.) @param serialized: a serializable value to be used to represent this state to external systems. This value should be hashable; L{str} is a good type to use. """ def decorator(stateMethod): state = MethodicalState( machine=self, method=stateMethod, serialized=serialized ) if initial: self._automaton.initialState = state return state return decorator @_keywords_only def input(self): """ Declare an input. This is a decorator for methods. """ def decorator(inputMethod): return MethodicalInput( automaton=self._automaton, method=inputMethod, symbol=self._symbol ) return decorator @_keywords_only def output(self): """ Declare an output. This is a decorator for methods. This method will be called when the state machine transitions to this state as specified in the decorated `output` method. """ def decorator(outputMethod): return MethodicalOutput(machine=self, method=outputMethod) return decorator def _oneTransition(self, startState, inputToken, endState, outputTokens, collector): """ See L{MethodicalState.upon}. """ # FIXME: tests for all of this (some of it is wrong) # if not isinstance(startState, MethodicalState): # raise NotImplementedError("start state {} isn't a state" # .format(startState)) # if not isinstance(inputToken, MethodicalInput): # raise NotImplementedError("start state {} isn't an input" # .format(inputToken)) # if not isinstance(endState, MethodicalState): # raise NotImplementedError("end state {} isn't a state" # .format(startState)) # for output in outputTokens: # if not isinstance(endState, MethodicalState): # raise NotImplementedError("output state {} isn't a state" # .format(endState)) self._automaton.addTransition( startState, inputToken, endState, tuple(outputTokens) ) inputToken.collectors[startState] = collector @_keywords_only def serializer(self): """ """ def decorator(decoratee): @wraps(decoratee) def serialize(oself): transitioner = _transitionerFromInstance( oself, self._symbol, self._automaton ) return decoratee(oself, transitioner._state.serialized) return serialize return decorator @_keywords_only def unserializer(self): """ """ def decorator(decoratee): @wraps(decoratee) def unserialize(oself, *args, **kwargs): state = decoratee(oself, *args, **kwargs) mapping = {} for eachState in self._automaton.states(): mapping[eachState.serialized] = eachState transitioner = _transitionerFromInstance( oself, self._symbol, self._automaton ) transitioner._state = mapping[state] return None # it's on purpose return unserialize return decorator @property def _setTrace(self) -> MethodicalTracer: return MethodicalTracer(self._automaton, self._symbol) def asDigraph(self): """ Generate a L{graphviz.Digraph} that represents this machine's states and transitions. @return: L{graphviz.Digraph} object; for more information, please see the documentation for U{graphviz} """ from ._visualize import makeDigraph return makeDigraph( self._automaton, stateAsString=lambda state: state.method.__name__, inputAsString=lambda input: input.method.__name__, outputAsString=lambda output: output.method.__name__, ) automat-25.4.16/src/automat/_runtimeproto.py000066400000000000000000000031661500000722400210600ustar00rootroot00000000000000""" Workaround for U{the lack of TypeForm }. """ from __future__ import annotations import sys from typing import TYPE_CHECKING, Callable, Protocol, TypeVar from inspect import signature, Signature T = TypeVar("T") ProtocolAtRuntime = Callable[[], T] def runtime_name(x: ProtocolAtRuntime[T]) -> str: return x.__name__ from inspect import getmembers, isfunction emptyProtocolMethods: frozenset[str] if not TYPE_CHECKING: emptyProtocolMethods = frozenset( name for name, each in getmembers(type("Example", tuple([Protocol]), {}), isfunction) ) def actuallyDefinedProtocolMethods(protocol: object) -> frozenset[str]: """ Attempt to ignore implementation details, and get all the methods that the protocol actually defines. that includes locally defined methods and also those defined in inherited superclasses. """ return ( frozenset(name for name, each in getmembers(protocol, isfunction)) - emptyProtocolMethods ) def _fixAnnotation(method: Callable[..., object], it: object, ann: str) -> None: annotation = getattr(it, ann) if isinstance(annotation, str): setattr(it, ann, eval(annotation, method.__globals__)) def _liveSignature(method: Callable[..., object]) -> Signature: """ Get a signature with evaluated annotations. """ # TODO: could this be replaced with get_type_hints? result = signature(method) for param in result.parameters.values(): _fixAnnotation(method, param, "_annotation") _fixAnnotation(method, result, "_return_annotation") return result automat-25.4.16/src/automat/_test/000077500000000000000000000000001500000722400167105ustar00rootroot00000000000000automat-25.4.16/src/automat/_test/__init__.py000066400000000000000000000000001500000722400210070ustar00rootroot00000000000000automat-25.4.16/src/automat/_test/test_core.py000066400000000000000000000066311500000722400212570ustar00rootroot00000000000000from unittest import TestCase from .._core import Automaton, NoTransition, Transitioner class CoreTests(TestCase): """ Tests for Automat's (currently private, implementation detail) core. """ def test_NoTransition(self): """ A L{NoTransition} exception describes the state and input symbol that caused it. """ # NoTransition requires two arguments with self.assertRaises(TypeError): NoTransition() state = "current-state" symbol = "transitionless-symbol" noTransitionException = NoTransition(state=state, symbol=symbol) self.assertIs(noTransitionException.symbol, symbol) self.assertIn(state, str(noTransitionException)) self.assertIn(symbol, str(noTransitionException)) def test_unhandledTransition(self) -> None: """ Automaton.unhandledTransition sets the outputs and end-state to be used for all unhandled transitions. """ a: Automaton[str, str, str] = Automaton("start") a.addTransition("oops-state", "check", "start", tuple(["checked"])) a.unhandledTransition("oops-state", ["oops-out"]) t = Transitioner(a, "start") self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) self.assertEqual(t.transition("check"), (["checked"], None)) self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) def test_noOutputForInput(self): """ L{Automaton.outputForInput} raises L{NoTransition} if no transition for that input is defined. """ a = Automaton() self.assertRaises(NoTransition, a.outputForInput, "no-state", "no-symbol") def test_oneTransition(self): """ L{Automaton.addTransition} adds its input symbol to L{Automaton.inputAlphabet}, all its outputs to L{Automaton.outputAlphabet}, and causes L{Automaton.outputForInput} to start returning the new state and output symbols. """ a = Automaton() a.addTransition("beginning", "begin", "ending", ["end"]) self.assertEqual(a.inputAlphabet(), {"begin"}) self.assertEqual(a.outputAlphabet(), {"end"}) self.assertEqual(a.outputForInput("beginning", "begin"), ("ending", ["end"])) self.assertEqual(a.states(), {"beginning", "ending"}) def test_oneTransition_nonIterableOutputs(self): """ L{Automaton.addTransition} raises a TypeError when given outputs that aren't iterable and doesn't add any transitions. """ a = Automaton() nonIterableOutputs = 1 self.assertRaises( TypeError, a.addTransition, "fromState", "viaSymbol", "toState", nonIterableOutputs, ) self.assertFalse(a.inputAlphabet()) self.assertFalse(a.outputAlphabet()) self.assertFalse(a.states()) self.assertFalse(a.allTransitions()) def test_initialState(self): """ L{Automaton.initialState} is a descriptor that sets the initial state if it's not yet set, and raises L{ValueError} if it is. """ a = Automaton() a.initialState = "a state" self.assertEqual(a.initialState, "a state") with self.assertRaises(ValueError): a.initialState = "another state" # FIXME: addTransition for transition that's been added before automat-25.4.16/src/automat/_test/test_discover.py000066400000000000000000000530631500000722400221460ustar00rootroot00000000000000import operator import os import shutil import sys import textwrap import tempfile from unittest import skipIf, TestCase def isTwistedInstalled(): try: __import__("twisted") except ImportError: return False else: return True class _WritesPythonModules(TestCase): """ A helper that enables generating Python module test fixtures. """ def setUp(self): super(_WritesPythonModules, self).setUp() from twisted.python.modules import getModule, PythonPath from twisted.python.filepath import FilePath self.getModule = getModule self.PythonPath = PythonPath self.FilePath = FilePath self.originalSysModules = set(sys.modules.keys()) self.savedSysPath = sys.path[:] self.pathDir = tempfile.mkdtemp() self.makeImportable(self.pathDir) def tearDown(self): super(_WritesPythonModules, self).tearDown() sys.path[:] = self.savedSysPath modulesToDelete = sys.modules.keys() - self.originalSysModules for module in modulesToDelete: del sys.modules[module] shutil.rmtree(self.pathDir) def makeImportable(self, path): sys.path.append(path) def writeSourceInto(self, source, path, moduleName): directory = self.FilePath(path) module = directory.child(moduleName) # FilePath always opens a file in binary mode - but that will # break on Python 3 with open(module.path, "w") as f: f.write(textwrap.dedent(source)) return self.PythonPath([directory.path]) def makeModule(self, source, path, moduleName): pythonModuleName, _ = os.path.splitext(moduleName) return self.writeSourceInto(source, path, moduleName)[pythonModuleName] def attributesAsDict(self, hasIterAttributes): return {attr.name: attr for attr in hasIterAttributes.iterAttributes()} def loadModuleAsDict(self, module): module.load() return self.attributesAsDict(module) def makeModuleAsDict(self, source, path, name): return self.loadModuleAsDict(self.makeModule(source, path, name)) @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class OriginalLocationTests(_WritesPythonModules): """ Tests that L{isOriginalLocation} detects when a L{PythonAttribute}'s FQPN refers to an object inside the module where it was defined. For example: A L{twisted.python.modules.PythonAttribute} with a name of 'foo.bar' that refers to a 'bar' object defined in module 'baz' does *not* refer to bar's original location, while a L{PythonAttribute} with a name of 'baz.bar' does. """ def setUp(self): super(OriginalLocationTests, self).setUp() from .._discover import isOriginalLocation self.isOriginalLocation = isOriginalLocation def test_failsWithNoModule(self): """ L{isOriginalLocation} returns False when the attribute refers to an object whose source module cannot be determined. """ source = """\ class Fake(object): pass hasEmptyModule = Fake() hasEmptyModule.__module__ = None """ moduleDict = self.makeModuleAsDict(source, self.pathDir, "empty_module_attr.py") self.assertFalse( self.isOriginalLocation(moduleDict["empty_module_attr.hasEmptyModule"]) ) def test_failsWithDifferentModule(self): """ L{isOriginalLocation} returns False when the attribute refers to an object outside of the module where that object was defined. """ originalSource = """\ class ImportThisClass(object): pass importThisObject = ImportThisClass() importThisNestingObject = ImportThisClass() importThisNestingObject.nestedObject = ImportThisClass() """ importingSource = """\ from original import (ImportThisClass, importThisObject, importThisNestingObject) """ self.makeModule(originalSource, self.pathDir, "original.py") importingDict = self.makeModuleAsDict( importingSource, self.pathDir, "importing.py" ) self.assertFalse( self.isOriginalLocation(importingDict["importing.ImportThisClass"]) ) self.assertFalse( self.isOriginalLocation(importingDict["importing.importThisObject"]) ) nestingObject = importingDict["importing.importThisNestingObject"] nestingObjectDict = self.attributesAsDict(nestingObject) nestedObject = nestingObjectDict[ "importing.importThisNestingObject.nestedObject" ] self.assertFalse(self.isOriginalLocation(nestedObject)) def test_succeedsWithSameModule(self): """ L{isOriginalLocation} returns True when the attribute refers to an object inside the module where that object was defined. """ mSource = textwrap.dedent( """ class ThisClassWasDefinedHere(object): pass anObject = ThisClassWasDefinedHere() aNestingObject = ThisClassWasDefinedHere() aNestingObject.nestedObject = ThisClassWasDefinedHere() """ ) mDict = self.makeModuleAsDict(mSource, self.pathDir, "m.py") self.assertTrue(self.isOriginalLocation(mDict["m.ThisClassWasDefinedHere"])) self.assertTrue(self.isOriginalLocation(mDict["m.aNestingObject"])) nestingObject = mDict["m.aNestingObject"] nestingObjectDict = self.attributesAsDict(nestingObject) nestedObject = nestingObjectDict["m.aNestingObject.nestedObject"] self.assertTrue(self.isOriginalLocation(nestedObject)) @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class FindMachinesViaWrapperTests(_WritesPythonModules): """ L{findMachinesViaWrapper} recursively yields FQPN, L{MethodicalMachine} pairs in and under a given L{twisted.python.modules.PythonModule} or L{twisted.python.modules.PythonAttribute}. """ def setUp(self): super(FindMachinesViaWrapperTests, self).setUp() from .._discover import findMachinesViaWrapper self.findMachinesViaWrapper = findMachinesViaWrapper def test_yieldsMachine(self): """ When given a L{twisted.python.modules.PythonAttribute} that refers directly to a L{MethodicalMachine}, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine rootMachine = MethodicalMachine() """ moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py") rootMachine = moduleDict["root.rootMachine"] self.assertIn( ("root.rootMachine", rootMachine.load()), list(self.findMachinesViaWrapper(rootMachine)), ) def test_yieldsTypeMachine(self) -> None: """ When given a L{twisted.python.modules.PythonAttribute} that refers directly to a L{TypeMachine}, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import TypeMachineBuilder from typing import Protocol, Callable class P(Protocol): def method(self) -> None: ... class C:... def buildBuilder() -> Callable[[C], P]: builder = TypeMachineBuilder(P, C) return builder.build() rootMachine = buildBuilder() """ moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py") rootMachine = moduleDict["root.rootMachine"] self.assertIn( ("root.rootMachine", rootMachine.load()), list(self.findMachinesViaWrapper(rootMachine)), ) def test_yieldsMachineInClass(self): """ When given a L{twisted.python.modules.PythonAttribute} that refers to a class that contains a L{MethodicalMachine} as a class variable, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine class PythonClass(object): _classMachine = MethodicalMachine() """ moduleDict = self.makeModuleAsDict(source, self.pathDir, "clsmod.py") PythonClass = moduleDict["clsmod.PythonClass"] self.assertIn( ("clsmod.PythonClass._classMachine", PythonClass.load()._classMachine), list(self.findMachinesViaWrapper(PythonClass)), ) def test_yieldsMachineInNestedClass(self): """ When given a L{twisted.python.modules.PythonAttribute} that refers to a nested class that contains a L{MethodicalMachine} as a class variable, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine class PythonClass(object): class NestedClass(object): _classMachine = MethodicalMachine() """ moduleDict = self.makeModuleAsDict(source, self.pathDir, "nestedcls.py") PythonClass = moduleDict["nestedcls.PythonClass"] self.assertIn( ( "nestedcls.PythonClass.NestedClass._classMachine", PythonClass.load().NestedClass._classMachine, ), list(self.findMachinesViaWrapper(PythonClass)), ) def test_yieldsMachineInModule(self): """ When given a L{twisted.python.modules.PythonModule} that refers to a module that contains a L{MethodicalMachine}, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine rootMachine = MethodicalMachine() """ module = self.makeModule(source, self.pathDir, "root.py") rootMachine = self.loadModuleAsDict(module)["root.rootMachine"].load() self.assertIn( ("root.rootMachine", rootMachine), list(self.findMachinesViaWrapper(module)) ) def test_yieldsMachineInClassInModule(self): """ When given a L{twisted.python.modules.PythonModule} that refers to the original module of a class containing a L{MethodicalMachine}, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine class PythonClass(object): _classMachine = MethodicalMachine() """ module = self.makeModule(source, self.pathDir, "clsmod.py") PythonClass = self.loadModuleAsDict(module)["clsmod.PythonClass"].load() self.assertIn( ("clsmod.PythonClass._classMachine", PythonClass._classMachine), list(self.findMachinesViaWrapper(module)), ) def test_yieldsMachineInNestedClassInModule(self): """ When given a L{twisted.python.modules.PythonModule} that refers to the original module of a nested class containing a L{MethodicalMachine}, L{findMachinesViaWrapper} yields that machine and its FQPN. """ source = """\ from automat import MethodicalMachine class PythonClass(object): class NestedClass(object): _classMachine = MethodicalMachine() """ module = self.makeModule(source, self.pathDir, "nestedcls.py") PythonClass = self.loadModuleAsDict(module)["nestedcls.PythonClass"].load() self.assertIn( ( "nestedcls.PythonClass.NestedClass._classMachine", PythonClass.NestedClass._classMachine, ), list(self.findMachinesViaWrapper(module)), ) def test_ignoresImportedClass(self): """ When given a L{twisted.python.modules.PythonAttribute} that refers to a class imported from another module, any L{MethodicalMachine}s on that class are ignored. This behavior ensures that a machine is only discovered on a class when visiting the module where that class was defined. """ originalSource = """ from automat import MethodicalMachine class PythonClass(object): _classMachine = MethodicalMachine() """ importingSource = """ from original import PythonClass """ self.makeModule(originalSource, self.pathDir, "original.py") importingModule = self.makeModule(importingSource, self.pathDir, "importing.py") self.assertFalse(list(self.findMachinesViaWrapper(importingModule))) def test_descendsIntoPackages(self): """ L{findMachinesViaWrapper} descends into packages to discover machines. """ pythonPath = self.PythonPath([self.pathDir]) package = self.FilePath(self.pathDir).child("test_package") package.makedirs() package.child("__init__.py").touch() source = """ from automat import MethodicalMachine class PythonClass(object): _classMachine = MethodicalMachine() rootMachine = MethodicalMachine() """ self.makeModule(source, package.path, "module.py") test_package = pythonPath["test_package"] machines = sorted( self.findMachinesViaWrapper(test_package), key=operator.itemgetter(0) ) moduleDict = self.loadModuleAsDict(test_package["module"]) rootMachine = moduleDict["test_package.module.rootMachine"].load() PythonClass = moduleDict["test_package.module.PythonClass"].load() expectedMachines = sorted( [ ("test_package.module.rootMachine", rootMachine), ( "test_package.module.PythonClass._classMachine", PythonClass._classMachine, ), ], key=operator.itemgetter(0), ) self.assertEqual(expectedMachines, machines) def test_infiniteLoop(self): """ L{findMachinesViaWrapper} ignores infinite loops. Note this test can't fail - it can only run forever! """ source = """ class InfiniteLoop(object): pass InfiniteLoop.loop = InfiniteLoop """ module = self.makeModule(source, self.pathDir, "loop.py") self.assertFalse(list(self.findMachinesViaWrapper(module))) @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class WrapFQPNTests(TestCase): """ Tests that ensure L{wrapFQPN} loads the L{twisted.python.modules.PythonModule} or L{twisted.python.modules.PythonAttribute} for a given FQPN. """ def setUp(self): from twisted.python.modules import PythonModule, PythonAttribute from .._discover import wrapFQPN, InvalidFQPN, NoModule, NoObject self.PythonModule = PythonModule self.PythonAttribute = PythonAttribute self.wrapFQPN = wrapFQPN self.InvalidFQPN = InvalidFQPN self.NoModule = NoModule self.NoObject = NoObject def assertModuleWrapperRefersTo(self, moduleWrapper, module): """ Assert that a L{twisted.python.modules.PythonModule} refers to a particular Python module. """ self.assertIsInstance(moduleWrapper, self.PythonModule) self.assertEqual(moduleWrapper.name, module.__name__) self.assertIs(moduleWrapper.load(), module) def assertAttributeWrapperRefersTo(self, attributeWrapper, fqpn, obj): """ Assert that a L{twisted.python.modules.PythonAttribute} refers to a particular Python object. """ self.assertIsInstance(attributeWrapper, self.PythonAttribute) self.assertEqual(attributeWrapper.name, fqpn) self.assertIs(attributeWrapper.load(), obj) def test_failsWithEmptyFQPN(self): """ L{wrapFQPN} raises L{InvalidFQPN} when given an empty string. """ with self.assertRaises(self.InvalidFQPN): self.wrapFQPN("") def test_failsWithBadDotting(self): """ " L{wrapFQPN} raises L{InvalidFQPN} when given a badly-dotted FQPN. (e.g., x..y). """ for bad in (".fails", "fails.", "this..fails"): with self.assertRaises(self.InvalidFQPN): self.wrapFQPN(bad) def test_singleModule(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonModule} referring to the single module a dotless FQPN describes. """ import os moduleWrapper = self.wrapFQPN("os") self.assertIsInstance(moduleWrapper, self.PythonModule) self.assertIs(moduleWrapper.load(), os) def test_failsWithMissingSingleModuleOrPackage(self): """ L{wrapFQPN} raises L{NoModule} when given a dotless FQPN that does not refer to a module or package. """ with self.assertRaises(self.NoModule): self.wrapFQPN("this is not an acceptable name!") def test_singlePackage(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonModule} referring to the single package a dotless FQPN describes. """ import xml self.assertModuleWrapperRefersTo(self.wrapFQPN("xml"), xml) def test_multiplePackages(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonModule} referring to the deepest package described by dotted FQPN. """ import xml.etree self.assertModuleWrapperRefersTo(self.wrapFQPN("xml.etree"), xml.etree) def test_multiplePackagesFinalModule(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonModule} referring to the deepest module described by dotted FQPN. """ import xml.etree.ElementTree self.assertModuleWrapperRefersTo( self.wrapFQPN("xml.etree.ElementTree"), xml.etree.ElementTree ) def test_singleModuleObject(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute} referring to the deepest object an FQPN names, traversing one module. """ import os self.assertAttributeWrapperRefersTo( self.wrapFQPN("os.path"), "os.path", os.path ) def test_multiplePackagesObject(self): """ L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute} referring to the deepest object described by an FQPN, descending through several packages. """ import xml.etree.ElementTree import automat for fqpn, obj in [ ("xml.etree.ElementTree.fromstring", xml.etree.ElementTree.fromstring), ("automat.MethodicalMachine.__doc__", automat.MethodicalMachine.__doc__), ]: self.assertAttributeWrapperRefersTo(self.wrapFQPN(fqpn), fqpn, obj) def test_failsWithMultiplePackagesMissingModuleOrPackage(self): """ L{wrapFQPN} raises L{NoObject} when given an FQPN that contains a missing attribute, module, or package. """ for bad in ("xml.etree.nope!", "xml.etree.nope!.but.the.rest.is.believable"): with self.assertRaises(self.NoObject): self.wrapFQPN(bad) @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class FindMachinesIntegrationTests(_WritesPythonModules): """ Integration tests to check that L{findMachines} yields all machines discoverable at or below an FQPN. """ SOURCE = """ from automat import MethodicalMachine class PythonClass(object): _machine = MethodicalMachine() ignored = "i am ignored" rootLevel = MethodicalMachine() ignored = "i am ignored" """ def setUp(self): super(FindMachinesIntegrationTests, self).setUp() from .._discover import findMachines self.findMachines = findMachines packageDir = self.FilePath(self.pathDir).child("test_package") packageDir.makedirs() self.pythonPath = self.PythonPath([self.pathDir]) self.writeSourceInto(self.SOURCE, packageDir.path, "__init__.py") subPackageDir = packageDir.child("subpackage") subPackageDir.makedirs() subPackageDir.child("__init__.py").touch() self.makeModule(self.SOURCE, subPackageDir.path, "module.py") self.packageDict = self.loadModuleAsDict(self.pythonPath["test_package"]) self.moduleDict = self.loadModuleAsDict( self.pythonPath["test_package"]["subpackage"]["module"] ) def test_discoverAll(self): """ Given a top-level package FQPN, L{findMachines} discovers all L{MethodicalMachine} instances in and below it. """ machines = sorted(self.findMachines("test_package"), key=operator.itemgetter(0)) tpRootLevel = self.packageDict["test_package.rootLevel"].load() tpPythonClass = self.packageDict["test_package.PythonClass"].load() mRLAttr = self.moduleDict["test_package.subpackage.module.rootLevel"] mRootLevel = mRLAttr.load() mPCAttr = self.moduleDict["test_package.subpackage.module.PythonClass"] mPythonClass = mPCAttr.load() expectedMachines = sorted( [ ("test_package.rootLevel", tpRootLevel), ("test_package.PythonClass._machine", tpPythonClass._machine), ("test_package.subpackage.module.rootLevel", mRootLevel), ( "test_package.subpackage.module.PythonClass._machine", mPythonClass._machine, ), ], key=operator.itemgetter(0), ) self.assertEqual(expectedMachines, machines) automat-25.4.16/src/automat/_test/test_methodical.py000066400000000000000000000507011500000722400224350ustar00rootroot00000000000000""" Tests for the public interface of Automat. """ from functools import reduce from unittest import TestCase from automat._methodical import ArgSpec, _getArgNames, _getArgSpec, _filterArgs from .. import MethodicalMachine, NoTransition from .. import _methodical class MethodicalTests(TestCase): """ Tests for L{MethodicalMachine}. """ def test_oneTransition(self): """ L{MethodicalMachine} provides a way for you to declare a state machine with inputs, outputs, and states as methods. When you have declared an input, an output, and a state, calling the input method in that state will produce the specified output. """ class Machination(object): machine = MethodicalMachine() @machine.input() def anInput(self): "an input" @machine.output() def anOutput(self): "an output" return "an-output-value" @machine.output() def anotherOutput(self): "another output" return "another-output-value" @machine.state(initial=True) def anState(self): "a state" @machine.state() def anotherState(self): "another state" anState.upon(anInput, enter=anotherState, outputs=[anOutput]) anotherState.upon(anInput, enter=anotherState, outputs=[anotherOutput]) m = Machination() self.assertEqual(m.anInput(), ["an-output-value"]) self.assertEqual(m.anInput(), ["another-output-value"]) def test_machineItselfIsPrivate(self): """ L{MethodicalMachine} is an implementation detail. If you attempt to access it on an instance of your class, you will get an exception. However, since tools may need to access it for the purposes of, for example, visualization, you may access it on the class itself. """ expectedMachine = MethodicalMachine() class Machination(object): machine = expectedMachine machination = Machination() with self.assertRaises(AttributeError) as cm: machination.machine self.assertIn( "MethodicalMachine is an implementation detail", str(cm.exception) ) self.assertIs(Machination.machine, expectedMachine) def test_outputsArePrivate(self): """ One of the benefits of using a state machine is that your output method implementations don't need to take invalid state transitions into account - the methods simply won't be called. This property would be broken if client code called output methods directly, so output methods are not directly visible under their names. """ class Machination(object): machine = MethodicalMachine() counter = 0 @machine.input() def anInput(self): "an input" @machine.output() def anOutput(self): self.counter += 1 @machine.state(initial=True) def state(self): "a machine state" state.upon(anInput, enter=state, outputs=[anOutput]) mach1 = Machination() mach1.anInput() self.assertEqual(mach1.counter, 1) mach2 = Machination() with self.assertRaises(AttributeError) as cm: mach2.anOutput self.assertEqual(mach2.counter, 0) self.assertIn( "Machination.anOutput is a state-machine output method; to " "produce this output, call an input method instead.", str(cm.exception), ) def test_multipleMachines(self): """ Two machines may co-exist happily on the same instance; they don't interfere with each other. """ class MultiMach(object): a = MethodicalMachine() b = MethodicalMachine() @a.input() def inputA(self): "input A" @b.input() def inputB(self): "input B" @a.state(initial=True) def initialA(self): "initial A" @b.state(initial=True) def initialB(self): "initial B" @a.output() def outputA(self): return "A" @b.output() def outputB(self): return "B" initialA.upon(inputA, initialA, [outputA]) initialB.upon(inputB, initialB, [outputB]) mm = MultiMach() self.assertEqual(mm.inputA(), ["A"]) self.assertEqual(mm.inputB(), ["B"]) def test_collectOutputs(self): """ Outputs can be combined with the "collector" argument to "upon". """ import operator class Machine(object): m = MethodicalMachine() @m.input() def input(self): "an input" @m.output() def outputA(self): return "A" @m.output() def outputB(self): return "B" @m.state(initial=True) def state(self): "a state" state.upon( input, state, [outputA, outputB], collector=lambda x: reduce(operator.add, x), ) m = Machine() self.assertEqual(m.input(), "AB") def test_methodName(self): """ Input methods preserve their declared names. """ class Mech(object): m = MethodicalMachine() @m.input() def declaredInputName(self): "an input" @m.state(initial=True) def aState(self): "state" m = Mech() with self.assertRaises(TypeError) as cm: m.declaredInputName("too", "many", "arguments") self.assertIn("declaredInputName", str(cm.exception)) def test_inputWithArguments(self): """ If an input takes an argument, it will pass that along to its output. """ class Mechanism(object): m = MethodicalMachine() @m.input() def input(self, x, y=1): "an input" @m.state(initial=True) def state(self): "a state" @m.output() def output(self, x, y=1): self._x = x return x + y state.upon(input, state, [output]) m = Mechanism() self.assertEqual(m.input(3), [4]) self.assertEqual(m._x, 3) def test_outputWithSubsetOfArguments(self): """ Inputs pass arguments that output will accept. """ class Mechanism(object): m = MethodicalMachine() @m.input() def input(self, x, y=1): "an input" @m.state(initial=True) def state(self): "a state" @m.output() def outputX(self, x): self._x = x return x @m.output() def outputY(self, y): self._y = y return y @m.output() def outputNoArgs(self): return None state.upon(input, state, [outputX, outputY, outputNoArgs]) m = Mechanism() # Pass x as positional argument. self.assertEqual(m.input(3), [3, 1, None]) self.assertEqual(m._x, 3) self.assertEqual(m._y, 1) # Pass x as key word argument. self.assertEqual(m.input(x=4), [4, 1, None]) self.assertEqual(m._x, 4) self.assertEqual(m._y, 1) # Pass y as positional argument. self.assertEqual(m.input(6, 3), [6, 3, None]) self.assertEqual(m._x, 6) self.assertEqual(m._y, 3) # Pass y as key word argument. self.assertEqual(m.input(5, y=2), [5, 2, None]) self.assertEqual(m._x, 5) self.assertEqual(m._y, 2) def test_inputFunctionsMustBeEmpty(self): """ The wrapped input function must have an empty body. """ # input functions are executed to assert that the signature matches, # but their body must be empty _methodical._empty() # chase coverage _methodical._docstring() class Mechanism(object): m = MethodicalMachine() with self.assertRaises(ValueError) as cm: @m.input() def input(self): "an input" list() # pragma: no cover self.assertEqual(str(cm.exception), "function body must be empty") # all three of these cases should be valid. Functions/methods with # docstrings produce slightly different bytecode than ones without. class MechanismWithDocstring(object): m = MethodicalMachine() @m.input() def input(self): "an input" @m.state(initial=True) def start(self): "starting state" start.upon(input, enter=start, outputs=[]) MechanismWithDocstring().input() class MechanismWithPass(object): m = MethodicalMachine() @m.input() def input(self): pass @m.state(initial=True) def start(self): "starting state" start.upon(input, enter=start, outputs=[]) MechanismWithPass().input() class MechanismWithDocstringAndPass(object): m = MethodicalMachine() @m.input() def input(self): "an input" pass @m.state(initial=True) def start(self): "starting state" start.upon(input, enter=start, outputs=[]) MechanismWithDocstringAndPass().input() class MechanismReturnsNone(object): m = MethodicalMachine() @m.input() def input(self): return None @m.state(initial=True) def start(self): "starting state" start.upon(input, enter=start, outputs=[]) MechanismReturnsNone().input() class MechanismWithDocstringAndReturnsNone(object): m = MethodicalMachine() @m.input() def input(self): "an input" return None @m.state(initial=True) def start(self): "starting state" start.upon(input, enter=start, outputs=[]) MechanismWithDocstringAndReturnsNone().input() def test_inputOutputMismatch(self): """ All the argument lists of the outputs for a given input must match; if one does not the call to C{upon} will raise a C{TypeError}. """ class Mechanism(object): m = MethodicalMachine() @m.input() def nameOfInput(self, a): "an input" @m.output() def outputThatMatches(self, a): "an output that matches" @m.output() def outputThatDoesntMatch(self, b): "an output that doesn't match" @m.state() def state(self): "a state" with self.assertRaises(TypeError) as cm: state.upon( nameOfInput, state, [outputThatMatches, outputThatDoesntMatch] ) self.assertIn("nameOfInput", str(cm.exception)) self.assertIn("outputThatDoesntMatch", str(cm.exception)) def test_stateLoop(self): """ It is possible to write a self-loop by omitting "enter" """ class Mechanism(object): m = MethodicalMachine() @m.input() def input(self): "an input" @m.input() def say_hi(self): "an input" @m.output() def _start_say_hi(self): return "hi" @m.state(initial=True) def start(self): "a state" def said_hi(self): "a state with no inputs" start.upon(input, outputs=[]) start.upon(say_hi, outputs=[_start_say_hi]) a_mechanism = Mechanism() [a_greeting] = a_mechanism.say_hi() self.assertEqual(a_greeting, "hi") def test_defaultOutputs(self): """ It is possible to write a transition with no outputs """ class Mechanism(object): m = MethodicalMachine() @m.input() def finish(self): "final transition" @m.state(initial=True) def start(self): "a start state" @m.state() def finished(self): "a final state" start.upon(finish, enter=finished) Mechanism().finish() def test_getArgNames(self): """ Type annotations should be included in the set of """ spec = ArgSpec( args=("a", "b"), varargs=None, varkw=None, defaults=None, kwonlyargs=(), kwonlydefaults=None, annotations=(("a", int), ("b", str)), ) self.assertEqual( _getArgNames(spec), {"a", "b", ("a", int), ("b", str)}, ) def test_filterArgs(self): """ filterArgs() should not filter the `args` parameter if outputSpec accepts `*args`. """ inputSpec = _getArgSpec(lambda *args, **kwargs: None) outputSpec = _getArgSpec(lambda *args, **kwargs: None) argsIn = () argsOut, _ = _filterArgs(argsIn, {}, inputSpec, outputSpec) self.assertIs(argsIn, argsOut) def test_multipleInitialStatesFailure(self): """ A L{MethodicalMachine} can only have one initial state. """ class WillFail(object): m = MethodicalMachine() @m.state(initial=True) def firstInitialState(self): "The first initial state -- this is OK." with self.assertRaises(ValueError): @m.state(initial=True) def secondInitialState(self): "The second initial state -- results in a ValueError." def test_multipleTransitionsFailure(self): """ A L{MethodicalMachine} can only have one transition per start/event pair. """ class WillFail(object): m = MethodicalMachine() @m.state(initial=True) def start(self): "We start here." @m.state() def end(self): "Rainbows end." @m.input() def event(self): "An event." start.upon(event, enter=end, outputs=[]) with self.assertRaises(ValueError): start.upon(event, enter=end, outputs=[]) def test_badTransitionForCurrentState(self): """ Calling any input method that lacks a transition for the machine's current state raises an informative L{NoTransition}. """ class OnlyOnePath(object): m = MethodicalMachine() @m.state(initial=True) def start(self): "Start state." @m.state() def end(self): "End state." @m.input() def advance(self): "Move from start to end." @m.input() def deadEnd(self): "A transition from nowhere to nowhere." start.upon(advance, end, []) machine = OnlyOnePath() with self.assertRaises(NoTransition) as cm: machine.deadEnd() self.assertIn("deadEnd", str(cm.exception)) self.assertIn("start", str(cm.exception)) machine.advance() with self.assertRaises(NoTransition) as cm: machine.deadEnd() self.assertIn("deadEnd", str(cm.exception)) self.assertIn("end", str(cm.exception)) def test_saveState(self): """ L{MethodicalMachine.serializer} is a decorator that modifies its decoratee's signature to take a "state" object as its first argument, which is the "serialized" argument to the L{MethodicalMachine.state} decorator. """ class Mechanism(object): m = MethodicalMachine() def __init__(self): self.value = 1 @m.state(serialized="first-state", initial=True) def first(self): "First state." @m.state(serialized="second-state") def second(self): "Second state." @m.serializer() def save(self, state): return { "machine-state": state, "some-value": self.value, } self.assertEqual( Mechanism().save(), { "machine-state": "first-state", "some-value": 1, }, ) def test_restoreState(self): """ L{MethodicalMachine.unserializer} decorates a function that becomes a machine-state unserializer; its return value is mapped to the C{serialized} parameter to C{state}, and the L{MethodicalMachine} associated with that instance's state is updated to that state. """ class Mechanism(object): m = MethodicalMachine() def __init__(self): self.value = 1 self.ranOutput = False @m.state(serialized="first-state", initial=True) def first(self): "First state." @m.state(serialized="second-state") def second(self): "Second state." @m.input() def input(self): "an input" @m.output() def output(self): self.value = 2 self.ranOutput = True return 1 @m.output() def output2(self): return 2 first.upon(input, second, [output], collector=lambda x: list(x)[0]) second.upon(input, second, [output2], collector=lambda x: list(x)[0]) @m.serializer() def save(self, state): return { "machine-state": state, "some-value": self.value, } @m.unserializer() def _restore(self, blob): self.value = blob["some-value"] return blob["machine-state"] @classmethod def fromBlob(cls, blob): self = cls() self._restore(blob) return self m1 = Mechanism() m1.input() blob = m1.save() m2 = Mechanism.fromBlob(blob) self.assertEqual(m2.ranOutput, False) self.assertEqual(m2.input(), 2) self.assertEqual( m2.save(), { "machine-state": "second-state", "some-value": 2, }, ) def test_allowBasicTypeAnnotations(self): """ L{MethodicalMachine} can operate with type annotations on inputs and outputs. """ class Mechanism(object): m = MethodicalMachine() @m.input() def an_input(self, arg: int): "An input" @m.output() def an_output(self, arg: int) -> int: return arg + 1 @m.state(initial=True) def state(self): "A state" state.upon(an_input, enter=state, outputs=[an_output]) mechanism = Mechanism() assert mechanism.an_input(2) == [3] # FIXME: error for wrong types on any call to _oneTransition # FIXME: better public API for .upon; maybe a context manager? # FIXME: when transitions are defined, validate that we can always get to # terminal? do we care about this? # FIXME: implementation (and use-case/example) for passing args from in to out # FIXME: possibly these need some kind of support from core # FIXME: wildcard state (in all states, when input X, emit Y and go to Z) # FIXME: wildcard input (in state X, when any input, emit Y and go to Z) # FIXME: combined wildcards (in any state for any input, emit Y go to Z) automat-25.4.16/src/automat/_test/test_trace.py000066400000000000000000000063431500000722400214250ustar00rootroot00000000000000from unittest import TestCase from .._methodical import MethodicalMachine class SampleObject(object): mm = MethodicalMachine() @mm.state(initial=True) def begin(self): "initial state" @mm.state() def middle(self): "middle state" @mm.state() def end(self): "end state" @mm.input() def go1(self): "sample input" @mm.input() def go2(self): "sample input" @mm.input() def back(self): "sample input" @mm.output() def out(self): "sample output" setTrace = mm._setTrace begin.upon(go1, middle, [out]) middle.upon(go2, end, [out]) end.upon(back, middle, []) middle.upon(back, begin, []) class TraceTests(TestCase): def test_only_inputs(self): traces = [] def tracer(old_state, input, new_state): traces.append((old_state, input, new_state)) return None # "I only care about inputs, not outputs" s = SampleObject() s.setTrace(tracer) s.go1() self.assertEqual( traces, [ ("begin", "go1", "middle"), ], ) s.go2() self.assertEqual( traces, [ ("begin", "go1", "middle"), ("middle", "go2", "end"), ], ) s.setTrace(None) s.back() self.assertEqual( traces, [ ("begin", "go1", "middle"), ("middle", "go2", "end"), ], ) s.go2() self.assertEqual( traces, [ ("begin", "go1", "middle"), ("middle", "go2", "end"), ], ) def test_inputs_and_outputs(self): traces = [] def tracer(old_state, input, new_state): traces.append((old_state, input, new_state, None)) def trace_outputs(output): traces.append((old_state, input, new_state, output)) return trace_outputs # "I care about outputs too" s = SampleObject() s.setTrace(tracer) s.go1() self.assertEqual( traces, [ ("begin", "go1", "middle", None), ("begin", "go1", "middle", "out"), ], ) s.go2() self.assertEqual( traces, [ ("begin", "go1", "middle", None), ("begin", "go1", "middle", "out"), ("middle", "go2", "end", None), ("middle", "go2", "end", "out"), ], ) s.setTrace(None) s.back() self.assertEqual( traces, [ ("begin", "go1", "middle", None), ("begin", "go1", "middle", "out"), ("middle", "go2", "end", None), ("middle", "go2", "end", "out"), ], ) s.go2() self.assertEqual( traces, [ ("begin", "go1", "middle", None), ("begin", "go1", "middle", "out"), ("middle", "go2", "end", None), ("middle", "go2", "end", "out"), ], ) automat-25.4.16/src/automat/_test/test_type_based.py000066400000000000000000000427201500000722400224450ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Callable, Generic, List, Protocol, TypeVar from unittest import TestCase, skipIf from .. import AlreadyBuiltError, NoTransition, TypeMachineBuilder, pep614 try: from zope.interface import Interface, implementer # type:ignore[import-untyped] except ImportError: hasInterface = False else: hasInterface = True class ISomething(Interface): def something() -> int: ... # type:ignore[misc,empty-body] T = TypeVar("T") class ProtocolForTesting(Protocol): def change(self) -> None: "Switch to the other state." def value(self) -> int: "Give a value specific to the given state." class ArgTaker(Protocol): def takeSomeArgs(self, arg1: int = 0, arg2: str = "") -> None: ... def value(self) -> int: ... class NoOpCore: "Just an object, you know?" @dataclass class Gen(Generic[T]): t: T def buildTestBuilder() -> tuple[ TypeMachineBuilder[ProtocolForTesting, NoOpCore], Callable[[NoOpCore], ProtocolForTesting], ]: builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) first = builder.state("first") second = builder.state("second") first.upon(ProtocolForTesting.change).to(second).returns(None) second.upon(ProtocolForTesting.change).to(first).returns(None) @pep614(first.upon(ProtocolForTesting.value).loop()) def firstValue(machine: ProtocolForTesting, core: NoOpCore) -> int: return 3 @pep614(second.upon(ProtocolForTesting.value).loop()) def secondValue(machine: ProtocolForTesting, core: NoOpCore) -> int: return 4 return builder, builder.build() builder, machineFactory = buildTestBuilder() def needsSomething(proto: ProtocolForTesting, core: NoOpCore, value: str) -> int: "we need data to build this state" return 3 # pragma: no cover def needsNothing(proto: ArgTaker, core: NoOpCore) -> str: return "state-specific data" # pragma: no cover class SimpleProtocol(Protocol): def method(self) -> None: "A method" class Counter(Protocol): def start(self) -> None: "enter the counting state" def increment(self) -> None: "increment the counter" def stop(self) -> int: "stop" @dataclass class Count: value: int = 0 class TypeMachineTests(TestCase): def test_oneTransition(self) -> None: machine = machineFactory(NoOpCore()) self.assertEqual(machine.value(), 3) machine.change() self.assertEqual(machine.value(), 4) self.assertEqual(machine.value(), 4) machine.change() self.assertEqual(machine.value(), 3) def test_stateSpecificData(self) -> None: builder = TypeMachineBuilder(Counter, NoOpCore) initial = builder.state("initial") counting = builder.state("counting", lambda machine, core: Count()) initial.upon(Counter.start).to(counting).returns(None) @pep614(counting.upon(Counter.increment).loop()) def incf(counter: Counter, core: NoOpCore, count: Count) -> None: count.value += 1 @pep614(counting.upon(Counter.stop).to(initial)) def finish(counter: Counter, core: NoOpCore, count: Count) -> int: return count.value machineFactory = builder.build() machine = machineFactory(NoOpCore()) machine.start() machine.increment() machine.increment() self.assertEqual(machine.stop(), 2) machine.start() machine.increment() self.assertEqual(machine.stop(), 1) def test_stateSpecificDataWithoutData(self) -> None: """ To facilitate common implementations of transition behavior methods, sometimes you want to implement a transition within a data state without taking a data parameter. To do this, pass the 'nodata=True' parameter to 'upon'. """ builder = TypeMachineBuilder(Counter, NoOpCore) initial = builder.state("initial") counting = builder.state("counting", lambda machine, core: Count()) startCalls = [] @pep614(initial.upon(Counter.start).to(counting)) @pep614(counting.upon(Counter.start, nodata=True).loop()) def start(counter: Counter, core: NoOpCore) -> None: startCalls.append("started!") @pep614(counting.upon(Counter.increment).loop()) def incf(counter: Counter, core: NoOpCore, count: Count) -> None: count.value += 1 @pep614(counting.upon(Counter.stop).to(initial)) def finish(counter: Counter, core: NoOpCore, count: Count) -> int: return count.value machineFactory = builder.build() machine = machineFactory(NoOpCore()) machine.start() self.assertEqual(len(startCalls), 1) machine.start() self.assertEqual(len(startCalls), 2) machine.increment() self.assertEqual(machine.stop(), 1) def test_incompleteTransitionDefinition(self) -> None: builder = TypeMachineBuilder(SimpleProtocol, NoOpCore) sample = builder.state("sample") sample.upon(SimpleProtocol.method).loop() # oops, no '.returns(None)' with self.assertRaises(ValueError) as raised: builder.build() self.assertIn( "incomplete transition from sample to sample upon SimpleProtocol.method", str(raised.exception), ) def test_dataToData(self) -> None: builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) @dataclass class Data1: value: int @dataclass class Data2: stuff: List[str] initial = builder.state("initial") counting = builder.state("counting", lambda proto, core: Data1(1)) appending = builder.state("appending", lambda proto, core: Data2([])) initial.upon(ProtocolForTesting.change).to(counting).returns(None) @pep614(counting.upon(ProtocolForTesting.value).loop()) def countup(p: ProtocolForTesting, c: NoOpCore, d: Data1) -> int: d.value *= 2 return d.value counting.upon(ProtocolForTesting.change).to(appending).returns(None) @pep614(appending.upon(ProtocolForTesting.value).loop()) def appendup(p: ProtocolForTesting, c: NoOpCore, d: Data2) -> int: d.stuff.extend("abc") return len(d.stuff) machineFactory = builder.build() machine = machineFactory(NoOpCore()) machine.change() self.assertEqual(machine.value(), 2) self.assertEqual(machine.value(), 4) machine.change() self.assertEqual(machine.value(), 3) self.assertEqual(machine.value(), 6) def test_dataFactoryArgs(self) -> None: """ Any data factory that takes arguments will constrain the allowed signature of all protocol methods that transition into that state. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) initial = builder.state("initial") data = builder.state("data", needsSomething) data2 = builder.state("data2", needsSomething) # toState = initial.to(data) # 'assertions' in the form of expected type errors: # (no data -> data) uponNoData = initial.upon(ProtocolForTesting.change) uponNoData.to(data) # type:ignore[arg-type] # (data -> data) uponData = data.upon(ProtocolForTesting.change) uponData.to(data2) # type:ignore[arg-type] def test_dataFactoryNoArgs(self) -> None: """ Inverse of C{test_dataFactoryArgs} where the data factory specifically does I{not} take arguments, but the input specified does. """ builder = TypeMachineBuilder(ArgTaker, NoOpCore) initial = builder.state("initial") data = builder.state("data", needsNothing) ( initial.upon(ArgTaker.takeSomeArgs) .to(data) # type:ignore[arg-type] .returns(None) ) def test_invalidTransition(self) -> None: """ Invalid transitions raise a NoTransition exception. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) builder.state("initial") factory = builder.build() machine = factory(NoOpCore()) with self.assertRaises(NoTransition): machine.change() def test_reentrancy(self) -> None: """ During the execution of a transition behavior implementation function, you may invoke other methods on your state machine. However, the execution of the behavior of those methods will be deferred until the current behavior method is done executing. In order to implement that deferral, we restrict the set of methods that can be invoked to those that return None. @note: it may be possible to implement deferral via Awaitables or Deferreds later, but we are starting simple. """ class SomeMethods(Protocol): def start(self) -> None: "Start the machine." def later(self) -> None: "Do some deferrable work." builder = TypeMachineBuilder(SomeMethods, NoOpCore) initial = builder.state("initial") second = builder.state("second") order = [] @pep614(initial.upon(SomeMethods.start).to(second)) def startup(methods: SomeMethods, core: NoOpCore) -> None: order.append("startup") methods.later() order.append("startup done") @pep614(second.upon(SomeMethods.later).loop()) def later(methods: SomeMethods, core: NoOpCore) -> None: order.append("later") machineFactory = builder.build() machine = machineFactory(NoOpCore()) machine.start() self.assertEqual(order, ["startup", "startup done", "later"]) def test_reentrancyNotNoneError(self) -> None: class SomeMethods(Protocol): def start(self) -> None: "Start the machine." def later(self) -> int: "Do some deferrable work." builder = TypeMachineBuilder(SomeMethods, NoOpCore) initial = builder.state("initial") second = builder.state("second") order = [] @pep614(initial.upon(SomeMethods.start).to(second)) def startup(methods: SomeMethods, core: NoOpCore) -> None: order.append("startup") methods.later() order.append("startup done") # pragma: no cover @pep614(second.upon(SomeMethods.later).loop()) def later(methods: SomeMethods, core: NoOpCore) -> int: order.append("later") return 3 machineFactory = builder.build() machine = machineFactory(NoOpCore()) with self.assertRaises(RuntimeError): machine.start() self.assertEqual(order, ["startup"]) # We do actually do the state transition, which happens *before* the # output is generated; TODO: maybe we should have exception handling # that transitions into an error state that requires explicit recovery? self.assertEqual(machine.later(), 3) self.assertEqual(order, ["startup", "later"]) def test_buildLock(self) -> None: """ ``.build()`` locks the builder so it can no longer be modified. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) state = builder.state("test-state") state2 = builder.state("state2") state3 = builder.state("state3") upon = state.upon(ProtocolForTesting.change) to = upon.to(state2) to2 = upon.to(state3) to.returns(None) with self.assertRaises(ValueError) as ve: to2.returns(None) with self.assertRaises(AlreadyBuiltError): to.returns(None) builder.build() with self.assertRaises(AlreadyBuiltError): builder.state("hello") with self.assertRaises(AlreadyBuiltError): builder.build() def test_methodMembership(self) -> None: """ Input methods must be members of their protocol. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) state = builder.state("test-state") def stateful(proto: ProtocolForTesting, core: NoOpCore) -> int: return 4 # pragma: no cover state2 = builder.state("state2", stateful) def change(self: ProtocolForTesting) -> None: ... def rogue(self: ProtocolForTesting) -> int: return 3 # pragma: no cover with self.assertRaises(ValueError): state.upon(change) with self.assertRaises(ValueError) as ve: state2.upon(change) with self.assertRaises(ValueError): state.upon(rogue) def test_startInAlternateState(self) -> None: """ The state machine can be started in an alternate state. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) one = builder.state("one") two = builder.state("two") @dataclass class Three: proto: ProtocolForTesting core: NoOpCore value: int = 0 three = builder.state("three", Three) one.upon(ProtocolForTesting.change).to(two).returns(None) one.upon(ProtocolForTesting.value).loop().returns(1) two.upon(ProtocolForTesting.change).to(three).returns(None) two.upon(ProtocolForTesting.value).loop().returns(2) @pep614(three.upon(ProtocolForTesting.value).loop()) def threevalue(proto: ProtocolForTesting, core: NoOpCore, three: Three) -> int: return 3 + three.value onetwothree = builder.build() # confirm positive behavior first, particularly the value of the three # state's change normal = onetwothree(NoOpCore()) self.assertEqual(normal.value(), 1) normal.change() self.assertEqual(normal.value(), 2) normal.change() self.assertEqual(normal.value(), 3) # now try deserializing it in each state self.assertEqual(onetwothree(NoOpCore()).value(), 1) self.assertEqual(onetwothree(NoOpCore(), two).value(), 2) self.assertEqual( onetwothree( NoOpCore(), three, lambda proto, core: Three(proto, core, 4) ).value(), 7, ) def test_genericData(self) -> None: """ Test to cover get_origin in generic assertion. """ builder = TypeMachineBuilder(ArgTaker, NoOpCore) one = builder.state("one") def dat( proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" ) -> Gen[int]: return Gen(arg1) two = builder.state("two", dat) one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) @pep614(two.upon(ArgTaker.value).loop()) def val(proto: ArgTaker, core: NoOpCore, data: Gen[int]) -> int: return data.t b = builder.build() m = b(NoOpCore()) m.takeSomeArgs(3) self.assertEqual(m.value(), 3) @skipIf(not hasInterface, "zope.interface not installed") def test_interfaceData(self) -> None: """ Test to cover providedBy assertion. """ builder = TypeMachineBuilder(ArgTaker, NoOpCore) one = builder.state("one") @implementer(ISomething) @dataclass class Something: val: int def something(self) -> int: return self.val def dat( proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" ) -> ISomething: return Something(arg1) # type:ignore[return-value] two = builder.state("two", dat) one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) @pep614(two.upon(ArgTaker.value).loop()) def val(proto: ArgTaker, core: NoOpCore, data: ISomething) -> int: return data.something() # type:ignore[misc] b = builder.build() m = b(NoOpCore()) m.takeSomeArgs(3) self.assertEqual(m.value(), 3) def test_noMethodsInAltStateDataFactory(self) -> None: """ When the state machine is received by a data factory during construction, it is in an invalid state. It may be invoked after construction is complete. """ builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) @dataclass class Data: value: int proto: ProtocolForTesting start = builder.state("start") data = builder.state("data", lambda proto, core: Data(3, proto)) @pep614(data.upon(ProtocolForTesting.value).loop()) def getval(proto: ProtocolForTesting, core: NoOpCore, data: Data) -> int: return data.value @pep614(start.upon(ProtocolForTesting.value).loop()) def minusone(proto: ProtocolForTesting, core: NoOpCore) -> int: return -1 factory = builder.build() self.assertEqual(factory(NoOpCore()).value(), -1) def touchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: return Data(proto.value(), proto) catchdata = [] def notouchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: catchdata.append(new := Data(4, proto)) return new with self.assertRaises(NoTransition): factory(NoOpCore(), data, touchproto) machine = factory(NoOpCore(), data, notouchproto) self.assertIs(machine, catchdata[0].proto) self.assertEqual(machine.value(), 4) automat-25.4.16/src/automat/_test/test_visualize.py000066400000000000000000000344471500000722400223500ustar00rootroot00000000000000from __future__ import annotations import functools import os import subprocess from dataclasses import dataclass from typing import Protocol from unittest import TestCase, skipIf from automat import TypeMachineBuilder, pep614 from .._methodical import MethodicalMachine from .._typed import TypeMachine from .test_discover import isTwistedInstalled def isGraphvizModuleInstalled(): """ Is the graphviz Python module installed? """ try: __import__("graphviz") except ImportError: return False else: return True def isGraphvizInstalled(): """ Are the graphviz tools installed? """ r, w = os.pipe() os.close(w) try: return not subprocess.call("dot", stdin=r, shell=True) finally: os.close(r) def sampleMachine(): """ Create a sample L{MethodicalMachine} with some sample states. """ mm = MethodicalMachine() class SampleObject(object): @mm.state(initial=True) def begin(self): "initial state" @mm.state() def end(self): "end state" @mm.input() def go(self): "sample input" @mm.output() def out(self): "sample output" begin.upon(go, end, [out]) so = SampleObject() so.go() return mm class Sample(Protocol): def go(self) -> None: ... class Core: ... def sampleTypeMachine() -> TypeMachine[Sample, Core]: """ Create a sample L{TypeMachine} with some sample states. """ builder = TypeMachineBuilder(Sample, Core) begin = builder.state("begin") def buildit(proto: Sample, core: Core) -> int: return 3 # pragma: no cover data = builder.state("data", buildit) end = builder.state("end") begin.upon(Sample.go).to(data).returns(None) data.upon(Sample.go).to(end).returns(None) @pep614(end.upon(Sample.go).to(begin)) def out(sample: Sample, core: Core) -> None: ... return builder.build() @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class ElementMakerTests(TestCase): """ L{elementMaker} generates HTML representing the specified element. """ def setUp(self): from .._visualize import elementMaker self.elementMaker = elementMaker def test_sortsAttrs(self): """ L{elementMaker} orders HTML attributes lexicographically. """ expected = r'
' self.assertEqual(expected, self.elementMaker("div", b="2", a="1", c="3")) def test_quotesAttrs(self): """ L{elementMaker} quotes HTML attributes according to DOT's quoting rule. See U{http://www.graphviz.org/doc/info/lang.html}, footnote 1. """ expected = r'
' self.assertEqual( expected, self.elementMaker("div", b='a " quote', a=1, c="a string") ) def test_noAttrs(self): """ L{elementMaker} should render an element with no attributes. """ expected = r"
" self.assertEqual(expected, self.elementMaker("div")) @dataclass class HTMLElement(object): """Holds an HTML element, as created by elementMaker.""" name: str children: list[HTMLElement] attributes: dict[str, str] def findElements(element, predicate): """ Recursively collect all elements in an L{HTMLElement} tree that match the optional predicate. """ if predicate(element): return [element] elif isLeaf(element): return [] return [ result for child in element.children for result in findElements(child, predicate) ] def isLeaf(element): """ This HTML element is actually leaf node. """ return not isinstance(element, HTMLElement) @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class TableMakerTests(TestCase): """ Tests that ensure L{tableMaker} generates HTML tables usable as labels in DOT graphs. For more information, read the "HTML-Like Labels" section of U{http://www.graphviz.org/doc/info/shapes.html}. """ def fakeElementMaker(self, name, *children, **attributes): return HTMLElement(name=name, children=children, attributes=attributes) def setUp(self): from .._visualize import tableMaker self.inputLabel = "input label" self.port = "the port" self.tableMaker = functools.partial(tableMaker, _E=self.fakeElementMaker) def test_inputLabelRow(self): """ The table returned by L{tableMaker} always contains the input symbol label in its first row, and that row contains one cell with a port attribute set to the provided port. """ def hasPort(element): return not isLeaf(element) and element.attributes.get("port") == self.port for outputLabels in ([], ["an output label"]): table = self.tableMaker(self.inputLabel, outputLabels, port=self.port) self.assertGreater(len(table.children), 0) inputLabelRow = table.children[0] portCandidates = findElements(table, hasPort) self.assertEqual(len(portCandidates), 1) self.assertEqual(portCandidates[0].name, "td") self.assertEqual(findElements(inputLabelRow, isLeaf), [self.inputLabel]) def test_noOutputLabels(self): """ L{tableMaker} does not add a colspan attribute to the input label's cell or a second row if there no output labels. """ table = self.tableMaker("input label", (), port=self.port) self.assertEqual(len(table.children), 1) (inputLabelRow,) = table.children self.assertNotIn("colspan", inputLabelRow.attributes) def test_withOutputLabels(self): """ L{tableMaker} adds a colspan attribute to the input label's cell equal to the number of output labels and a second row that contains the output labels. """ table = self.tableMaker( self.inputLabel, ("output label 1", "output label 2"), port=self.port ) self.assertEqual(len(table.children), 2) inputRow, outputRow = table.children def hasCorrectColspan(element): return ( not isLeaf(element) and element.name == "td" and element.attributes.get("colspan") == "2" ) self.assertEqual(len(findElements(inputRow, hasCorrectColspan)), 1) self.assertEqual( findElements(outputRow, isLeaf), ["output label 1", "output label 2"] ) @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class IntegrationTests(TestCase): """ Tests which make sure Graphviz can understand the output produced by Automat. """ def test_validGraphviz(self) -> None: """ C{graphviz} emits valid graphviz data. """ digraph = sampleMachine().asDigraph() text = "".join(digraph).encode("utf-8") p = subprocess.Popen("dot", stdin=subprocess.PIPE, stdout=subprocess.PIPE) out, err = p.communicate(text) self.assertEqual(p.returncode, 0) @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class SpotChecks(TestCase): """ Tests to make sure that the output contains salient features of the machine being generated. """ def test_containsMachineFeatures(self): """ The output of L{graphviz.Digraph} should contain the names of the states, inputs, outputs in the state machine. """ gvout = "".join(sampleMachine().asDigraph()) self.assertIn("begin", gvout) self.assertIn("end", gvout) self.assertIn("go", gvout) self.assertIn("out", gvout) def test_containsTypeMachineFeatures(self): """ The output of L{graphviz.Digraph} should contain the names of the states, inputs, outputs in the state machine. """ gvout = "".join(sampleTypeMachine().asDigraph()) self.assertIn("begin", gvout) self.assertIn("end", gvout) self.assertIn("go", gvout) self.assertIn("data:buildit", gvout) self.assertIn("out", gvout) class RecordsDigraphActions(object): """ Records calls made to L{FakeDigraph}. """ def __init__(self): self.reset() def reset(self): self.renderCalls = [] self.saveCalls = [] class FakeDigraph(object): """ A fake L{graphviz.Digraph}. Instantiate it with a L{RecordsDigraphActions}. """ def __init__(self, recorder): self._recorder = recorder def render(self, **kwargs): self._recorder.renderCalls.append(kwargs) def save(self, **kwargs): self._recorder.saveCalls.append(kwargs) class FakeMethodicalMachine(object): """ A fake L{MethodicalMachine}. Instantiate it with a L{FakeDigraph} """ def __init__(self, digraph): self._digraph = digraph def asDigraph(self): return self._digraph @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class VisualizeToolTests(TestCase): def setUp(self): self.digraphRecorder = RecordsDigraphActions() self.fakeDigraph = FakeDigraph(self.digraphRecorder) self.fakeProgname = "tool-test" self.fakeSysPath = ["ignored"] self.collectedOutput = [] self.fakeFQPN = "fake.fqpn" def collectPrints(self, *args): self.collectedOutput.append(" ".join(args)) def fakeFindMachines(self, fqpn): yield fqpn, FakeMethodicalMachine(self.fakeDigraph) def tool( self, progname=None, argv=None, syspath=None, findMachines=None, print=None ): from .._visualize import tool return tool( _progname=progname or self.fakeProgname, _argv=argv or [self.fakeFQPN], _syspath=syspath or self.fakeSysPath, _findMachines=findMachines or self.fakeFindMachines, _print=print or self.collectPrints, ) def test_checksCurrentDirectory(self): """ L{tool} adds '' to sys.path to ensure L{automat._discover.findMachines} searches the current directory. """ self.tool(argv=[self.fakeFQPN]) self.assertEqual(self.fakeSysPath[0], "") def test_quietHidesOutput(self): """ Passing -q/--quiet hides all output. """ self.tool(argv=[self.fakeFQPN, "--quiet"]) self.assertFalse(self.collectedOutput) self.tool(argv=[self.fakeFQPN, "-q"]) self.assertFalse(self.collectedOutput) def test_onlySaveDot(self): """ Passing an empty string for --image-directory/-i disables rendering images. """ for arg in ("--image-directory", "-i"): self.digraphRecorder.reset() self.collectedOutput = [] self.tool(argv=[self.fakeFQPN, arg, ""]) self.assertFalse(any("image" in line for line in self.collectedOutput)) self.assertEqual(len(self.digraphRecorder.saveCalls), 1) (call,) = self.digraphRecorder.saveCalls self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"]) self.assertFalse(self.digraphRecorder.renderCalls) def test_saveOnlyImage(self): """ Passing an empty string for --dot-directory/-d disables saving dot files. """ for arg in ("--dot-directory", "-d"): self.digraphRecorder.reset() self.collectedOutput = [] self.tool(argv=[self.fakeFQPN, arg, ""]) self.assertFalse(any("dot" in line for line in self.collectedOutput)) self.assertEqual(len(self.digraphRecorder.renderCalls), 1) (call,) = self.digraphRecorder.renderCalls self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"]) self.assertTrue(call["cleanup"]) self.assertFalse(self.digraphRecorder.saveCalls) def test_saveDotAndImagesInDifferentDirectories(self): """ Passing different directories to --image-directory and --dot-directory writes images and dot files to those directories. """ imageDirectory = "image" dotDirectory = "dot" self.tool( argv=[ self.fakeFQPN, "--image-directory", imageDirectory, "--dot-directory", dotDirectory, ] ) self.assertTrue(any("image" in line for line in self.collectedOutput)) self.assertTrue(any("dot" in line for line in self.collectedOutput)) self.assertEqual(len(self.digraphRecorder.renderCalls), 1) (renderCall,) = self.digraphRecorder.renderCalls self.assertEqual(renderCall["directory"], imageDirectory) self.assertTrue(renderCall["cleanup"]) self.assertEqual(len(self.digraphRecorder.saveCalls), 1) (saveCall,) = self.digraphRecorder.saveCalls self.assertEqual(saveCall["directory"], dotDirectory) def test_saveDotAndImagesInSameDirectory(self): """ Passing the same directory to --image-directory and --dot-directory writes images and dot files to that one directory. """ directory = "imagesAndDot" self.tool( argv=[ self.fakeFQPN, "--image-directory", directory, "--dot-directory", directory, ] ) self.assertTrue(any("image and dot" in line for line in self.collectedOutput)) self.assertEqual(len(self.digraphRecorder.renderCalls), 1) (renderCall,) = self.digraphRecorder.renderCalls self.assertEqual(renderCall["directory"], directory) self.assertFalse(renderCall["cleanup"]) self.assertFalse(len(self.digraphRecorder.saveCalls)) automat-25.4.16/src/automat/_typed.py000066400000000000000000000572141500000722400174410ustar00rootroot00000000000000# -*- test-case-name: automat._test.test_type_based -*- from __future__ import annotations import sys from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, get_origin, Any, Callable, Generic, Iterable, Literal, Protocol, TypeVar, overload, ) if TYPE_CHECKING: from graphviz import Digraph try: from zope.interface.interface import InterfaceClass # type:ignore[import-untyped] except ImportError: hasInterface = False else: hasInterface = True if sys.version_info < (3, 10): from typing_extensions import Concatenate, ParamSpec, TypeAlias else: from typing import Concatenate, ParamSpec, TypeAlias from ._core import Automaton, Transitioner from ._runtimeproto import ( ProtocolAtRuntime, _liveSignature, actuallyDefinedProtocolMethods, runtime_name, ) class AlreadyBuiltError(Exception): """ The L{TypeMachine} is already built, and thus can no longer be modified. """ InputProtocol = TypeVar("InputProtocol") Core = TypeVar("Core") Data = TypeVar("Data") P = ParamSpec("P") P1 = ParamSpec("P1") R = TypeVar("R") OtherData = TypeVar("OtherData") Decorator = Callable[[Callable[P, R]], Callable[P, R]] FactoryParams = ParamSpec("FactoryParams") OtherFactoryParams = ParamSpec("OtherFactoryParams") def pep614(t: R) -> R: """ This is a workaround for Python 3.8, which has U{some restrictions on its grammar for decorators }, and makes C{@state.to(other).upon(Protocol.input)} invalid syntax; for code that needs to run on these older Python versions, you can do C{@pep614(state.to(other).upon(Protocol.input))} instead. """ return t @dataclass() class TransitionRegistrar(Generic[P, P1, R]): """ This is a record of a transition that need finalizing; it is the result of calling L{TypeMachineBuilder.state} and then ``.upon(input).to(state)`` on the result of that. It can be used as a decorator, like:: registrar = state.upon(Proto.input).to(state2) @registrar def inputImplementation(proto: Proto, core: Core) -> Result: ... Or, it can be used used to implement a constant return value with L{TransitionRegistrar.returns}, like:: registrar = state.upon(Proto.input).to(state2) registrar.returns(value) Type parameter P: the precise signature of the decorated implementation callable. Type parameter P1: the precise signature of the input method from the outward-facing state-machine protocol. Type parameter R: the return type of both the protocol method and the input method. """ _signature: Callable[P1, R] _old: AnyState _new: AnyState _nodata: bool = False _callback: Callable[P, R] | None = None def __post_init__(self) -> None: self._old.builder._registrars.append(self) def __call__(self, impl: Callable[P, R]) -> Callable[P, R]: """ Finalize it with C{__call__} to indicate that there is an implementation to the transition, which can be treated as an output. """ if self._callback is not None: raise AlreadyBuiltError( f"already registered transition from {self._old.name!r} to {self._new.name!r}" ) self._callback = impl builder = self._old.builder assert builder is self._new.builder, "states must be from the same builder" builder._automaton.addTransition( self._old, self._signature.__name__, self._new, tuple(self._new._produceOutputs(impl, self._old, self._nodata)), ) return impl def returns(self, result: R) -> None: """ Finalize it with C{.returns(constant)} to indicate that there is no method body, and the given result can just be yielded each time after the state transition. The only output generated in this case would be the data-construction factory for the target state. """ def constant(*args: object, **kwargs: object) -> R: return result constant.__name__ = f"returns({result})" self(constant) def _checkComplete(self) -> None: """ Raise an exception if the user forgot to decorate a method implementation or supply a return value for this transition. """ # TODO: point at the line where `.to`/`.loop`/`.upon` are called so the # user can more immediately see the incomplete transition if not self._callback: raise ValueError( f"incomplete transition from {self._old.name} to " f"{self._new.name} upon {self._signature.__qualname__}: " "remember to use the transition as a decorator or call " "`.returns` on it." ) @dataclass class UponFromNo(Generic[InputProtocol, Core, P, R]): """ Type parameter P: the signature of the input method. """ old: TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...] input: Callable[Concatenate[InputProtocol, P], R] @overload def to( self, state: TypedState[InputProtocol, Core] ) -> TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R]: ... @overload def to( self, state: TypedDataState[InputProtocol, Core, OtherData, P], ) -> TransitionRegistrar[ Concatenate[InputProtocol, Core, P], Concatenate[InputProtocol, P], R, ]: ... def to( self, state: ( TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, P] ), ) -> ( TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R] | TransitionRegistrar[ Concatenate[InputProtocol, Core, P], Concatenate[InputProtocol, P], R, ] ): """ Declare a state transition to a new state. """ return TransitionRegistrar(self.input, self.old, state, True) def loop(self) -> TransitionRegistrar[ Concatenate[InputProtocol, Core, P], Concatenate[InputProtocol, P], R, ]: """ Register a transition back to the same state. """ return TransitionRegistrar(self.input, self.old, self.old, True) @dataclass class UponFromData(Generic[InputProtocol, Core, P, R, Data]): """ Type parameter P: the signature of the input method. """ old: TypedDataState[InputProtocol, Core, Data, ...] input: Callable[Concatenate[InputProtocol, P], R] @overload def to( self, state: TypedState[InputProtocol, Core] ) -> TransitionRegistrar[ Concatenate[InputProtocol, Core, Data, P], Concatenate[InputProtocol, P], R ]: ... @overload def to( self, state: TypedDataState[InputProtocol, Core, OtherData, P], ) -> TransitionRegistrar[ Concatenate[InputProtocol, Core, Data, P], Concatenate[InputProtocol, P], R, ]: ... def to( self, state: ( TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, P] ), ) -> ( TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R] | TransitionRegistrar[ Concatenate[InputProtocol, Core, Data, P], Concatenate[InputProtocol, P], R, ] ): """ Declare a state transition to a new state. """ return TransitionRegistrar(self.input, self.old, state) def loop(self) -> TransitionRegistrar[ Concatenate[InputProtocol, Core, Data, P], Concatenate[InputProtocol, P], R, ]: """ Register a transition back to the same state. """ return TransitionRegistrar(self.input, self.old, self.old) @dataclass(frozen=True) class TypedState(Generic[InputProtocol, Core]): """ The result of L{.state() }. """ name: str builder: TypeMachineBuilder[InputProtocol, Core] = field(repr=False) def upon( self, input: Callable[Concatenate[InputProtocol, P], R] ) -> UponFromNo[InputProtocol, Core, P, R]: ".upon()" self.builder._checkMembership(input) return UponFromNo(self, input) def _produceOutputs( self, impl: Callable[..., object], old: ( TypedDataState[InputProtocol, Core, OtherData, OtherFactoryParams] | TypedState[InputProtocol, Core] ), nodata: bool = False, ) -> Iterable[SomeOutput]: yield MethodOutput._fromImpl(impl, isinstance(old, TypedDataState)) @dataclass(frozen=True) class TypedDataState(Generic[InputProtocol, Core, Data, FactoryParams]): name: str builder: TypeMachineBuilder[InputProtocol, Core] = field(repr=False) factory: Callable[Concatenate[InputProtocol, Core, FactoryParams], Data] @overload def upon( self, input: Callable[Concatenate[InputProtocol, P], R] ) -> UponFromData[InputProtocol, Core, P, R, Data]: ... @overload def upon( self, input: Callable[Concatenate[InputProtocol, P], R], nodata: Literal[False] ) -> UponFromData[InputProtocol, Core, P, R, Data]: ... @overload def upon( self, input: Callable[Concatenate[InputProtocol, P], R], nodata: Literal[True] ) -> UponFromNo[InputProtocol, Core, P, R]: ... def upon( self, input: Callable[Concatenate[InputProtocol, P], R], nodata: bool = False, ) -> ( UponFromData[InputProtocol, Core, P, R, Data] | UponFromNo[InputProtocol, Core, P, R] ): self.builder._checkMembership(input) if nodata: return UponFromNo(self, input) else: return UponFromData(self, input) def _produceOutputs( self, impl: Callable[..., object], old: ( TypedDataState[InputProtocol, Core, OtherData, OtherFactoryParams] | TypedState[InputProtocol, Core] ), nodata: bool, ) -> Iterable[SomeOutput]: if self is not old: yield DataOutput(self.factory) yield MethodOutput._fromImpl( impl, isinstance(old, TypedDataState) and not nodata ) AnyState: TypeAlias = "TypedState[Any, Any] | TypedDataState[Any, Any, Any, Any]" @dataclass class TypedInput: name: str class SomeOutput(Protocol): """ A state machine output. """ @property def name(self) -> str: "read-only name property" def __call__(*args: Any, **kwargs: Any) -> Any: ... def __hash__(self) -> int: "must be hashable" @dataclass class InputImplementer(Generic[InputProtocol, Core]): """ An L{InputImplementer} implements an input protocol in terms of a state machine. When the factory returned from L{TypeMachine} """ __automat_core__: Core __automat_transitioner__: Transitioner[ TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, object, ...], str, SomeOutput, ] __automat_data__: object | None = None __automat_postponed__: list[Callable[[], None]] | None = None def implementMethod( method: Callable[..., object], ) -> Callable[..., object]: """ Construct a function for populating in the synthetic provider of the Input Protocol to a L{TypeMachineBuilder}. It should have a signature matching that of the C{method} parameter, a function from that protocol. """ methodInput = method.__name__ # side-effects can be re-ordered until later. If you need to compute a # value in your method, then obviously it can't be invoked reentrantly. returnAnnotation = _liveSignature(method).return_annotation returnsNone = returnAnnotation is None def implementation( self: InputImplementer[InputProtocol, Core], *args: object, **kwargs: object ) -> object: transitioner = self.__automat_transitioner__ dataAtStart = self.__automat_data__ if self.__automat_postponed__ is not None: if not returnsNone: raise RuntimeError( f"attempting to reentrantly run {method.__qualname__} " f"but it wants to return {returnAnnotation!r} not None" ) def rerunme() -> None: implementation(self, *args, **kwargs) self.__automat_postponed__.append(rerunme) return None postponed = self.__automat_postponed__ = [] try: [outputs, tracer] = transitioner.transition(methodInput) result: Any = None for output in outputs: # here's the idea: there will be a state-setup output and a # state-teardown output. state-setup outputs are added to the # *beginning* of any entry into a state, so that by the time you # are running the *implementation* of a method that has entered # that state, the protocol is in a self-consistent state and can # run reentrant outputs. not clear that state-teardown outputs are # necessary result = output(self, dataAtStart, *args, **kwargs) finally: self.__automat_postponed__ = None while postponed: postponed.pop(0)() return result implementation.__qualname__ = implementation.__name__ = ( f"" ) return implementation @dataclass(frozen=True) class MethodOutput(Generic[Core]): """ This is the thing that goes into the automaton's outputs list, and thus (per the implementation of L{implementMethod}) takes the 'self' of the InputImplementer instance (i.e. the synthetic protocol implementation) and the previous result computed by the former output, which will be None initially. """ method: Callable[..., Any] requiresData: bool _assertion: Callable[[object], None] @classmethod def _fromImpl( cls: type[MethodOutput[Core]], method: Callable[..., Any], requiresData: bool ) -> MethodOutput[Core]: parameter = None annotation: type[object] = object def assertion(data: object) -> None: """ No assertion about the data. """ # Do our best to compute the declared signature, so that we caan verify # it's the right type. We can't always do that. try: sig = _liveSignature(method) except NameError: ... # An inner function may refer to type aliases that only appear as # local variables, and those are just lost here; give up. else: if requiresData: # 0: self, 1: self.__automat_core__, 2: self.__automat_data__ declaredParams = list(sig.parameters.values()) if len(declaredParams) >= 3: parameter = declaredParams[2] annotation = parameter.annotation origin = get_origin(annotation) if origin is not None: annotation = origin if hasInterface and isinstance(annotation, InterfaceClass): def assertion(data: object) -> None: assert annotation.providedBy(data), ( f"expected {parameter} to provide {annotation} " f"but got {type(data)} instead" ) else: def assertion(data: object) -> None: assert isinstance(data, annotation), ( f"expected {parameter} to be {annotation} " f"but got {type(data)} instead" ) return cls(method, requiresData, assertion) @property def name(self) -> str: return f"{self.method.__name__}" def __call__( self, machine: InputImplementer[InputProtocol, Core], dataAtStart: Data, /, *args: object, **kwargs: object, ) -> object: extraArgs = [machine, machine.__automat_core__] if self.requiresData: self._assertion(dataAtStart) extraArgs += [dataAtStart] # if anything is invoked reentrantly here, then we can't possibly have # set __automat_data__ and the data argument to the reentrant method # will be wrong. we *need* to split out the construction / state-enter # hook, because it needs to run separately. return self.method(*extraArgs, *args, **kwargs) @dataclass(frozen=True) class DataOutput(Generic[Data]): """ Construct an output for the given data objects. """ dataFactory: Callable[..., Data] @property def name(self) -> str: return f"data:{self.dataFactory.__name__}" def __call__( realself, self: InputImplementer[InputProtocol, Core], dataAtStart: object, *args: object, **kwargs: object, ) -> Data: newData = realself.dataFactory(self, self.__automat_core__, *args, **kwargs) self.__automat_data__ = newData return newData INVALID_WHILE_DESERIALIZING: TypedState[Any, Any] = TypedState( "automat:invalid-while-deserializing", None, # type:ignore[arg-type] ) @dataclass(frozen=True) class TypeMachine(Generic[InputProtocol, Core]): """ A L{TypeMachine} is a factory for instances of C{InputProtocol}. """ __automat_type__: type[InputImplementer[InputProtocol, Core]] __automat_automaton__: Automaton[ TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...], str, SomeOutput, ] @overload def __call__(self, core: Core) -> InputProtocol: ... @overload def __call__( self, core: Core, state: TypedState[InputProtocol, Core] ) -> InputProtocol: ... @overload def __call__( self, core: Core, state: TypedDataState[InputProtocol, Core, OtherData, ...], dataFactory: Callable[[InputProtocol, Core], OtherData], ) -> InputProtocol: ... def __call__( self, core: Core, state: ( TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, OtherData, ...] | None ) = None, dataFactory: Callable[[InputProtocol, Core], OtherData] | None = None, ) -> InputProtocol: """ Construct an instance of C{InputProtocol} from an instance of the C{Core} protocol. """ if state is None: state = initial = self.__automat_automaton__.initialState elif isinstance(state, TypedDataState): assert dataFactory is not None, "data state requires a data factory" # Ensure that the machine is in a state with *no* transitions while # we are doing the initial construction of its state-specific data. initial = INVALID_WHILE_DESERIALIZING else: initial = state internals: InputImplementer[InputProtocol, Core] = self.__automat_type__( core, txnr := Transitioner(self.__automat_automaton__, initial) ) result: InputProtocol = internals # type:ignore[assignment] if dataFactory is not None: internals.__automat_data__ = dataFactory(result, core) txnr._state = state return result def asDigraph(self) -> Digraph: from ._visualize import makeDigraph return makeDigraph( self.__automat_automaton__, stateAsString=lambda state: state.name, inputAsString=lambda input: input, outputAsString=lambda output: output.name, ) @dataclass(eq=False) class TypeMachineBuilder(Generic[InputProtocol, Core]): """ The main entry-point into Automat, used to construct a factory for instances of C{InputProtocol} that take an instance of C{Core}. Describe the machine with L{TypeMachineBuilder.state} L{.upon } L{.to }, then build it with L{TypeMachineBuilder.build}, like so:: from typing import Protocol class Inputs(Protocol): def method(self) -> None: ... class Core: ... from automat import TypeMachineBuilder builder = TypeMachineBuilder(Inputs, Core) state = builder.state("state") state.upon(Inputs.method).loop().returns(None) Machine = builder.build() machine = Machine(Core()) machine.method() """ # Public constructor parameters. inputProtocol: ProtocolAtRuntime[InputProtocol] coreType: type[Core] # Internal state, not in the constructor. _automaton: Automaton[ TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...], str, SomeOutput, ] = field(default_factory=Automaton, repr=False, init=False) _initial: bool = field(default=True, init=False) _registrars: list[TransitionRegistrar[..., ..., Any]] = field( default_factory=list, init=False ) _built: bool = field(default=False, init=False) @overload def state(self, name: str) -> TypedState[InputProtocol, Core]: ... @overload def state( self, name: str, dataFactory: Callable[Concatenate[InputProtocol, Core, P], Data], ) -> TypedDataState[InputProtocol, Core, Data, P]: ... def state( self, name: str, dataFactory: Callable[Concatenate[InputProtocol, Core, P], Data] | None = None, ) -> TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Data, P]: """ Construct a state. """ if self._built: raise AlreadyBuiltError( "Cannot add states to an already-built state machine." ) if dataFactory is None: state = TypedState(name, self) if self._initial: self._initial = False self._automaton.initialState = state return state else: assert not self._initial, "initial state cannot require state-specific data" return TypedDataState(name, self, dataFactory) def build(self) -> TypeMachine[InputProtocol, Core]: """ Create a L{TypeMachine}, and prevent further modification to the state machine being built. """ # incompleteness check if self._built: raise AlreadyBuiltError("Cannot build a state machine twice.") self._built = True for registrar in self._registrars: registrar._checkComplete() # We were only hanging on to these for error-checking purposes, so we # can drop them now. del self._registrars[:] runtimeType: type[InputImplementer[InputProtocol, Core]] = type( f"Typed<{runtime_name(self.inputProtocol)}>", tuple([InputImplementer]), { method_name: implementMethod(getattr(self.inputProtocol, method_name)) for method_name in actuallyDefinedProtocolMethods(self.inputProtocol) }, ) return TypeMachine(runtimeType, self._automaton) def _checkMembership(self, input: Callable[..., object]) -> None: """ Ensure that ``input`` is a valid member function of the input protocol, not just a function that happens to take the right first argument. """ if (checked := getattr(self.inputProtocol, input.__name__, None)) is not input: raise ValueError( f"{input.__qualname__} is not a member of {self.inputProtocol.__module__}.{self.inputProtocol.__name__}" ) automat-25.4.16/src/automat/_visualize.py000066400000000000000000000145601500000722400203240ustar00rootroot00000000000000from __future__ import annotations import argparse import sys from functools import wraps from typing import Callable, Iterator import graphviz from ._core import Automaton, Input, Output, State from ._discover import findMachines from ._methodical import MethodicalMachine from ._typed import TypeMachine, InputProtocol, Core def _gvquote(s: str) -> str: return '"{}"'.format(s.replace('"', r"\"")) def _gvhtml(s: str) -> str: return "<{}>".format(s) def elementMaker(name: str, *children: str, **attrs: str) -> str: """ Construct a string from the HTML element description. """ formattedAttrs = " ".join( "{}={}".format(key, _gvquote(str(value))) for key, value in sorted(attrs.items()) ) formattedChildren = "".join(children) return "<{name} {attrs}>{children}".format( name=name, attrs=formattedAttrs, children=formattedChildren ) def tableMaker( inputLabel: str, outputLabels: list[str], port: str, _E: Callable[..., str] = elementMaker, ) -> str: """ Construct an HTML table to label a state transition. """ colspan = {} if outputLabels: colspan["colspan"] = str(len(outputLabels)) inputLabelCell = _E( "td", _E("font", inputLabel, face="menlo-italic"), color="purple", port=port, **colspan, ) pointSize = {"point-size": "9"} outputLabelCells = [ _E("td", _E("font", outputLabel, **pointSize), color="pink") for outputLabel in outputLabels ] rows = [_E("tr", inputLabelCell)] if outputLabels: rows.append(_E("tr", *outputLabelCells)) return _E("table", *rows) def escapify(x: Callable[[State], str]) -> Callable[[State], str]: @wraps(x) def impl(t: State) -> str: return x(t).replace("<", "<").replace(">", ">") return impl def makeDigraph( automaton: Automaton[State, Input, Output], inputAsString: Callable[[Input], str] = repr, outputAsString: Callable[[Output], str] = repr, stateAsString: Callable[[State], str] = repr, ) -> graphviz.Digraph: """ Produce a L{graphviz.Digraph} object from an automaton. """ inputAsString = escapify(inputAsString) outputAsString = escapify(outputAsString) stateAsString = escapify(stateAsString) digraph = graphviz.Digraph( graph_attr={"pack": "true", "dpi": "100"}, node_attr={"fontname": "Menlo"}, edge_attr={"fontname": "Menlo"}, ) for state in automaton.states(): if state is automaton.initialState: stateShape = "bold" fontName = "Menlo-Bold" else: stateShape = "" fontName = "Menlo" digraph.node( stateAsString(state), fontame=fontName, shape="ellipse", style=stateShape, color="blue", ) for n, eachTransition in enumerate(automaton.allTransitions()): inState, inputSymbol, outState, outputSymbols = eachTransition thisTransition = "t{}".format(n) inputLabel = inputAsString(inputSymbol) port = "tableport" table = tableMaker( inputLabel, [outputAsString(outputSymbol) for outputSymbol in outputSymbols], port=port, ) digraph.node(thisTransition, label=_gvhtml(table), margin="0.2", shape="none") digraph.edge( stateAsString(inState), "{}:{}:w".format(thisTransition, port), arrowhead="none", ) digraph.edge("{}:{}:e".format(thisTransition, port), stateAsString(outState)) return digraph def tool( _progname: str = sys.argv[0], _argv: list[str] = sys.argv[1:], _syspath: list[str] = sys.path, _findMachines: Callable[ [str], Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]], ] = findMachines, _print: Callable[..., None] = print, ) -> None: """ Entry point for command line utility. """ DESCRIPTION = """ Visualize automat.MethodicalMachines as graphviz graphs. """ EPILOG = """ You must have the graphviz tool suite installed. Please visit http://www.graphviz.org for more information. """ if _syspath[0]: _syspath.insert(0, "") argumentParser = argparse.ArgumentParser( prog=_progname, description=DESCRIPTION, epilog=EPILOG ) argumentParser.add_argument( "fqpn", help="A Fully Qualified Path name" " representing where to find machines.", ) argumentParser.add_argument( "--quiet", "-q", help="suppress output", default=False, action="store_true" ) argumentParser.add_argument( "--dot-directory", "-d", help="Where to write out .dot files.", default=".automat_visualize", ) argumentParser.add_argument( "--image-directory", "-i", help="Where to write out image files.", default=".automat_visualize", ) argumentParser.add_argument( "--image-type", "-t", help="The image format.", choices=graphviz.FORMATS, default="png", ) argumentParser.add_argument( "--view", "-v", help="View rendered graphs with" " default image viewer", default=False, action="store_true", ) args = argumentParser.parse_args(_argv) explicitlySaveDot = args.dot_directory and ( not args.image_directory or args.image_directory != args.dot_directory ) if args.quiet: def _print(*args): pass for fqpn, machine in _findMachines(args.fqpn): _print(fqpn, "...discovered") digraph = machine.asDigraph() if explicitlySaveDot: digraph.save(filename="{}.dot".format(fqpn), directory=args.dot_directory) _print(fqpn, "...wrote dot into", args.dot_directory) if args.image_directory: deleteDot = not args.dot_directory or explicitlySaveDot digraph.format = args.image_type digraph.render( filename="{}.dot".format(fqpn), directory=args.image_directory, view=args.view, cleanup=deleteDot, ) if deleteDot: msg = "...wrote image into" else: msg = "...wrote image and dot into" _print(fqpn, msg, args.image_directory) automat-25.4.16/src/automat/py.typed000066400000000000000000000000001500000722400172570ustar00rootroot00000000000000automat-25.4.16/tox.ini000066400000000000000000000022711500000722400146460ustar00rootroot00000000000000[tox] envlist = lint,{pypy3,py38,py310,py311,py312}-mypy,coverage-clean,{pypy3,py38,py310,py311,py312}-{extras,noextras},coverage-report,docs isolated_build = true [testenv] deps = extras: graphviz>=0.4.9 extras: Twisted>=16.2.0 mypy: mypy mypy: graphviz>=0.4.9 mypy: Twisted>=16.2.0 coverage pytest commands = {extras,noextras}: coverage run --parallel --source src -m pytest -s -rfEsx src/automat/_test mypy: mypy {posargs:src/automat} depends = coverage-clean [testenv:coverage-clean] deps = coverage skip_install = true commands = coverage erase depends = [testenv:coverage-report] deps = coverage skip_install = true commands = coverage combine coverage xml coverage report -m depends = {pypy3,py38,py310,py311}-{extras,noextras} [testenv:benchmark] deps = pytest-benchmark commands = pytest --benchmark-only benchmark/ [testenv:lint] deps = black commands = black --check src [testenv:pypy3-benchmark] deps = {[testenv:benchmark]deps} commands = {[testenv:benchmark]commands} [testenv:docs] usedevelop = True changedir = docs deps = -r docs/requirements.txt commands = python -m sphinx -M html . _build basepython = python3.12 automat-25.4.16/typical_example_happy.py000066400000000000000000000131421500000722400202650ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field from itertools import count from typing import Callable, List, Protocol from automat import TypeMachineBuilder # scaffolding; no state machines yet @dataclass class Request: id: int = field(default_factory=count(1).__next__) @dataclass class RequestGetter: cb: Callable[[Request], None] | None = None def startGettingRequests(self, cb: Callable[[Request], None]) -> None: self.cb = cb @dataclass(repr=False) class Task: performer: TaskPerformer request: Request done: Callable[[Task, bool], None] active: bool = True number: int = field(default_factory=count(1000).__next__) def __repr__(self) -> str: return f"" def complete(self, success: bool) -> None: # Also a state machine, maybe? print("complete", success) self.performer.activeTasks.remove(self) self.active = False self.done(self, success) def stop(self) -> None: self.complete(False) @dataclass class TaskPerformer: activeTasks: List[Task] = field(default_factory=list) taskLimit: int = 3 def performTask(self, r: Request, done: Callable[[Task, bool], None]) -> Task: self.activeTasks.append(it := Task(self, r, done)) return it class ConnectionCoordinator(Protocol): def start(self) -> None: "kick off the whole process" def requestReceived(self, r: Request) -> None: "a task was received" def taskComplete(self, task: Task, success: bool) -> None: "task complete" def atCapacity(self) -> None: "we're at capacity stop handling requests" def headroom(self) -> None: "one of the tasks completed" def cleanup(self) -> None: "clean everything up" @dataclass class ConnectionState: getter: RequestGetter performer: TaskPerformer allDone: Callable[[Task], None] queue: List[Request] = field(default_factory=list) def buildMachine() -> Callable[[ConnectionState], ConnectionCoordinator]: builder = TypeMachineBuilder(ConnectionCoordinator, ConnectionState) Initial = builder.state("Initial") Requested = builder.state("Requested") AtCapacity = builder.state("AtCapacity") CleaningUp = builder.state("CleaningUp") Requested.upon(ConnectionCoordinator.atCapacity).to(AtCapacity).returns(None) Requested.upon(ConnectionCoordinator.headroom).loop().returns(None) CleaningUp.upon(ConnectionCoordinator.headroom).loop().returns(None) CleaningUp.upon(ConnectionCoordinator.cleanup).loop().returns(None) @Initial.upon(ConnectionCoordinator.start).to(Requested) def startup(coord: ConnectionCoordinator, core: ConnectionState) -> None: core.getter.startGettingRequests(coord.requestReceived) @AtCapacity.upon(ConnectionCoordinator.requestReceived).loop() def requestReceived( coord: ConnectionCoordinator, core: ConnectionState, r: Request ) -> None: print("buffering request", r) core.queue.append(r) @AtCapacity.upon(ConnectionCoordinator.headroom).to(Requested) def headroom(coord: ConnectionCoordinator, core: ConnectionState) -> None: "nothing to do, just transition to Requested state" unhandledRequest = core.queue.pop() print("dequeueing", unhandledRequest) coord.requestReceived(unhandledRequest) @Requested.upon(ConnectionCoordinator.requestReceived).loop() def requestedRequest( coord: ConnectionCoordinator, core: ConnectionState, r: Request ) -> None: print("immediately handling request", r) core.performer.performTask(r, coord.taskComplete) if len(core.performer.activeTasks) >= core.performer.taskLimit: coord.atCapacity() @Initial.upon(ConnectionCoordinator.taskComplete).loop() @Requested.upon(ConnectionCoordinator.taskComplete).loop() @AtCapacity.upon(ConnectionCoordinator.taskComplete).loop() @CleaningUp.upon(ConnectionCoordinator.taskComplete).loop() def taskComplete( c: ConnectionCoordinator, s: ConnectionState, task: Task, success: bool ) -> None: if success: c.cleanup() s.allDone(task) else: c.headroom() @Requested.upon(ConnectionCoordinator.cleanup).to(CleaningUp) @AtCapacity.upon(ConnectionCoordinator.cleanup).to(CleaningUp) def cleanup(coord: ConnectionCoordinator, core: ConnectionState): # We *don't* want to recurse in here; stopping tasks will cause # taskComplete! while core.performer.activeTasks: core.performer.activeTasks[-1].stop() return builder.build() ConnectionMachine = buildMachine() def begin( r: RequestGetter, t: TaskPerformer, done: Callable[[Task], None] ) -> ConnectionCoordinator: machine = ConnectionMachine(ConnectionState(r, t, done)) machine.start() return machine def story() -> None: rget = RequestGetter() tper = TaskPerformer() def yay(t: Task) -> None: print("yay") m = begin(rget, tper, yay) cb = rget.cb assert cb is not None cb(Request()) cb(Request()) cb(Request()) cb(Request()) cb(Request()) cb(Request()) cb(Request()) print([each for each in tper.activeTasks]) sc: ConnectionState = m.__automat_core__ # type:ignore print(sc.queue) tper.activeTasks[0].complete(False) tper.activeTasks[0].complete(False) print([each for each in tper.activeTasks]) print(sc.queue) tper.activeTasks[0].complete(True) print([each for each in tper.activeTasks]) if __name__ == "__main__": story()