From ff14d6293882f8b9625606bf790ea020a1401cc2 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 1 Nov 2024 16:28:27 -0300 Subject: [PATCH 1/6] fix: Fix nested events inside loops leaking memory by referencing the previous 'event_data' when calling the next event on queue (#485) * fix: Fix recursion leaking memory by referencing the previous 'event_data' when calling the next event on queue --- .github/workflows/python-package.yml | 5 + docs/actions.md | 4 - poetry.lock | 270 ++++++++++++---------- pyproject.toml | 54 ++--- statemachine/contrib/diagram.py | 4 - statemachine/event.py | 12 + tests/examples/recursive_event_machine.py | 39 ++++ tests/testcases/issue480.md | 2 +- 8 files changed, 219 insertions(+), 171 deletions(-) create mode 100644 tests/examples/recursive_event_machine.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bb611171..71cf6293 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -42,6 +42,11 @@ jobs: - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --no-interaction --no-root --all-extras + - name: Install old pydot for 3.7 only + if: matrix.python-version == 3.7 + run: | + source .venv/bin/activate + pip install pydot==2.0.0 #---------------------------------------------- # run ruff #---------------------------------------------- diff --git a/docs/actions.md b/docs/actions.md index 10526a17..83ed3afd 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -135,8 +135,6 @@ Use the `enter` or `exit` params available on the `State` constructor. ```{hint} It's also possible to use an event name as action. - -**Be careful to not introduce recursion errors** that will raise `RecursionError` exception. ``` ### Bind state actions using decorator syntax @@ -221,8 +219,6 @@ using the patterns: ```{hint} It's also possible to use an event name as action to chain transitions. - -**Be careful to not introduce recursion errors**, like `loop = initial.to.itself(after="loop")`, that will raise `RecursionError` exception. ``` ### Bind transition actions using decorator syntax diff --git a/poetry.lock b/poetry.lock index 64c8725e..e670c52f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -90,101 +90,116 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, ] [[package]] @@ -275,24 +290,24 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] name = "django" -version = "5.1.1" +version = "5.1.2" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" files = [ - {file = "Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f"}, - {file = "Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2"}, + {file = "Django-5.1.2-py3-none-any.whl", hash = "sha256:f11aa87ad8d5617171e3f77e1d5d16f004b79a2cf5d2e1d2b97a6a1f8e9ba5ed"}, + {file = "Django-5.1.2.tar.gz", hash = "sha256:bd7376f90c99f96b643722eee676498706c9fd7dc759f55ebfaf2c08ebcdf4f0"}, ] [package.dependencies] @@ -377,15 +392,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.8" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" files = [ - {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, - {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "imagesize" version = "1.4.1" @@ -554,13 +572,13 @@ files = [ [[package]] name = "mdit-py-plugins" -version = "0.4.1" +version = "0.4.2" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" files = [ - {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"}, - {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"}, + {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, + {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, ] [package.dependencies] @@ -853,22 +871,22 @@ files = [ [[package]] name = "pydot" -version = "2.0.0" +version = "3.0.2" description = "Python interface to Graphviz's Dot" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydot-2.0.0-py3-none-any.whl", hash = "sha256:408a47913ea7bd5d2d34b274144880c1310c4aee901f353cf21fe2e526a4ea28"}, - {file = "pydot-2.0.0.tar.gz", hash = "sha256:60246af215123fa062f21cd791be67dda23a6f280df09f68919e637a1e4f3235"}, + {file = "pydot-3.0.2-py3-none-any.whl", hash = "sha256:99cedaa55d04abb0b2bc56d9981a6da781053dd5ac75c428e8dd53db53f90b14"}, + {file = "pydot-3.0.2.tar.gz", hash = "sha256:9180da540b51b3aa09fbf81140b3edfbe2315d778e8589a7d0a4a69c41332bae"}, ] [package.dependencies] -pyparsing = ">=3" +pyparsing = ">=3.0.9" [package.extras] -dev = ["black", "chardet"] +dev = ["chardet", "parameterized", "ruff"] release = ["zest.releaser[recommended]"] -tests = ["black", "chardet", "tox"] +tests = ["chardet", "parameterized", "pytest", "pytest-cov", "pytest-xdist[psutil]", "ruff", "tox"] [[package]] name = "pygments" @@ -1034,13 +1052,13 @@ dev = ["black", "flake8", "pre-commit"] [[package]] name = "pytz" -version = "2024.1" +version = "2024.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, ] [[package]] @@ -1262,13 +1280,13 @@ rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] [[package]] name = "sphinx-gallery" -version = "0.17.1" +version = "0.18.0" description = "A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts." optional = false python-versions = ">=3.8" files = [ - {file = "sphinx_gallery-0.17.1-py3-none-any.whl", hash = "sha256:0a1142a15a9d63169fe7b12167dc028891fb8db31bfc6d7de03ba0d68d591830"}, - {file = "sphinx_gallery-0.17.1.tar.gz", hash = "sha256:c9969abcc5ca8c24496014da8260833b8c3ccdb32c17716b5ba66f2e0a3cc183"}, + {file = "sphinx_gallery-0.18.0-py3-none-any.whl", hash = "sha256:54317366e77b182672797e5b46ab13cca9a27eafc3142c59dc4c211d4afe3420"}, + {file = "sphinx_gallery-0.18.0.tar.gz", hash = "sha256:4b5b5bc305348c01d00cf66ad852cfd2dd8b67f7f32ae3e2820c01557b3f92f9"}, ] [package.dependencies] @@ -1496,24 +1514,24 @@ files = [ [[package]] name = "tzdata" -version = "2024.1" +version = "2024.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, - {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] [[package]] name = "urllib3" -version = "2.2.2" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] @@ -1524,13 +1542,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.4" +version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"}, - {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"}, + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, ] [package.dependencies] @@ -1564,4 +1582,4 @@ diagrams = ["pydot"] [metadata] lock-version = "2.0" python-versions = ">=3.7" -content-hash = "13f2f8f744cef83106ef7bf39cd95ff5ceae370d65f9ea71a9bcb3dd535f72e0" +content-hash = "81f4766436451eede0444b4c45cbee8f9a26d73b27166f3f768f1e4d1051e259" diff --git a/pyproject.toml b/pyproject.toml index 0f60d012..54258e9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,19 +3,17 @@ name = "python-statemachine" version = "2.3.6" description = "Python Finite State Machines made easy." authors = ["Fernando Macedo "] -maintainers = [ - "Fernando Macedo ", -] +maintainers = ["Fernando Macedo "] license = "MIT license" readme = "README.md" homepage = "https://github.com/fgmacedo/python-statemachine" -packages = [ - {include = "statemachine"}, - {include = "statemachine/**/*.py" }, -] +packages = [{ include = "statemachine" }, { include = "statemachine/**/*.py" }] include = [ { path = "statemachine/locale/**/*.po", format = "sdist" }, - { path = "statemachine/locale/**/*.mo", format = ["sdist", "wheel"] } + { path = "statemachine/locale/**/*.mo", format = [ + "sdist", + "wheel", + ] }, ] classifiers = [ "Intended Audience :: Developers", @@ -35,7 +33,7 @@ classifiers = [ [tool.poetry.dependencies] python = ">=3.7" -pydot = { version = ">=2.0.0", optional = true } +pydot = { version = ">=2.0.0", optional = true, python = ">3.8" } [tool.poetry.extras] diagrams = ["pydot"] @@ -59,7 +57,7 @@ pytest-django = { version = "^4.8.0", python = ">3.8" } Sphinx = { version = "*", python = ">3.8" } myst-parser = { version = "*", python = ">3.8" } sphinx-gallery = { version = "*", python = ">3.8" } -pillow = { version ="*", python = ">3.8" } +pillow = { version = "*", python = ">3.8" } sphinx-autobuild = { version = "*", python = ">3.8" } furo = { version = "^2024.5.6", python = ">3.8" } sphinx-copybutton = { version = "^0.5.2", python = ">3.8" } @@ -72,9 +70,7 @@ build-backend = "poetry.core.masonry.api" addopts = "--ignore=docs/conf.py --ignore=docs/auto_examples/ --ignore=docs/_build/ --ignore=tests/examples/ --cov --cov-config .coveragerc --doctest-glob='*.md' --doctest-modules --doctest-continue-on-failure --benchmark-autosave --benchmark-group-by=name" doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL" asyncio_mode = "auto" -markers = [ - """slow: marks tests as slow (deselect with '-m "not slow"')""", -] +markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""] python_files = ["tests.py", "test_*.py", "*_tests.py"] [tool.mypy] @@ -85,19 +81,11 @@ disable_error_code = "annotation-unchecked" mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/django_project" [[tool.mypy.overrides]] -module = [ - 'django.*', - 'pytest.*', - 'pydot.*', - 'sphinx_gallery.*', -] +module = ['django.*', 'pytest.*', 'pydot.*', 'sphinx_gallery.*'] ignore_missing_imports = true [tool.flake8] -ignore = [ - "E231", - "W503", -] +ignore = ["E231", "W503"] max-line-length = 99 [tool.ruff] @@ -131,10 +119,10 @@ exclude = [ # Enable Pyflakes and pycodestyle rules. select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort "UP", # pyupgrade "C", # flake8-comprehensions "B", # flake8-bugbear @@ -169,14 +157,8 @@ convention = "google" branch = true relative_files = true data_file = ".coverage" -source = [ - "statemachine", -] -omit = [ - "*test*.py", - "tmp/*", - "pytest_cov", -] +source = ["statemachine"] +omit = ["*test*.py", "tmp/*", "pytest_cov"] [tool.coverage.report] show_missing = true exclude_lines = [ @@ -190,7 +172,7 @@ exclude_lines = [ # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", - "if TYPE_CHECKING", + "if TYPE_CHECKING", ] [tool.coverage.html] diff --git a/statemachine/contrib/diagram.py b/statemachine/contrib/diagram.py index 8e74440f..2e684057 100644 --- a/statemachine/contrib/diagram.py +++ b/statemachine/contrib/diagram.py @@ -166,10 +166,6 @@ def quickchart_write_svg(sm: StateMachine, path: str): >>> sm = OrderControl() >>> print(sm._graph().to_string()) digraph list { - fontname=Arial; - fontsize=10; - label=OrderControl; - rankdir=LR; ... To give you an example, we included this method that will serialize the dot, request the graph diff --git a/statemachine/event.py b/statemachine/event.py index a2551810..b40df882 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -8,6 +8,17 @@ if TYPE_CHECKING: from .statemachine import StateMachine +_event_data_kwargs = { + "event_data", + "machine", + "event", + "model", + "transition", + "state", + "source", + "target", +} + class Event: def __init__(self, name: str): @@ -17,6 +28,7 @@ def __repr__(self): return f"{type(self).__name__}({self.name!r})" def trigger(self, machine: "StateMachine", *args, **kwargs): + kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs} trigger_data = TriggerData( machine=machine, event=self.name, diff --git a/tests/examples/recursive_event_machine.py b/tests/examples/recursive_event_machine.py new file mode 100644 index 00000000..df410140 --- /dev/null +++ b/tests/examples/recursive_event_machine.py @@ -0,0 +1,39 @@ +""" +Looping state machine +===================== + +This example demonstrates that you can call an event as a side-effect of another event. +The event will be put on an internal queue and processed in the same loop after the previous event +in the queue is processed. + +""" + +from statemachine import State +from statemachine import StateMachine + + +class MyStateMachine(StateMachine): + startup = State(initial=True) + test = State() + + counter = 0 + do_startup = startup.to(test, after="do_test") + do_test = test.to.itself(after="do_test") + + def on_enter_state(self, target, event): + self.counter += 1 + print(f"{self.counter:>3}: Entering {target} from {event}") + + if self.counter >= 5: + raise StopIteration + + +# %% +# Let's create an instance and test the machine. + +sm = MyStateMachine() + +try: + sm.do_startup() +except StopIteration: + pass diff --git a/tests/testcases/issue480.md b/tests/testcases/issue480.md index b86d37e6..71b78d37 100644 --- a/tests/testcases/issue480.md +++ b/tests/testcases/issue480.md @@ -12,7 +12,7 @@ Should be possible to trigger an event on the initial state activation handler. >>> >>> class MyStateMachine(StateMachine): ... State_1 = State(initial=True) -... State_2 = State() +... State_2 = State(final=True) ... Trans_1 = State_1.to(State_2) ... ... def __init__(self): From 1275cd45832dd5624c111f57f851a45b2f65d3fe Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 2 Nov 2024 11:03:25 -0300 Subject: [PATCH 2/6] feat: Conditionals with boolean algebra (#487) --- docs/guards.md | 20 +++++ statemachine/callbacks.py | 72 ++++++++++++++--- statemachine/dispatcher.py | 5 +- statemachine/spec_parser.py | 79 +++++++++++++++++++ tests/examples/lor_machine.py | 102 ++++++++++++++++++++++++ tests/test_conditions_algebra.py | 65 +++++++++++++++ tests/test_spec_parser.py | 131 +++++++++++++++++++++++++++++++ 7 files changed, 462 insertions(+), 12 deletions(-) create mode 100644 statemachine/spec_parser.py create mode 100644 tests/examples/lor_machine.py create mode 100644 tests/test_conditions_algebra.py create mode 100644 tests/test_spec_parser.py diff --git a/docs/guards.md b/docs/guards.md index f98b270d..a361cc71 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -42,11 +42,31 @@ unless * Single condition: `unless="condition"` * Multiple conditions: `unless=["condition1", "condition2"]` +Conditions also support [Boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra) expressions, allowing you to use compound logic within transition guards. You can use both standard Python logical operators (`not`, `and`, `or`) as well as classic Boolean algebra symbols: + +- `!` for `not` +- `^` for `and` +- `v` for `or` + +For example: + +```python +start.to(end, cond="frodo_has_ring and gandalf_present or !sauron_alive") +``` + +Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent. + + ```{seealso} See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of combining multiple transitions to the same event. ``` +```{seealso} +See {ref}`sphx_glr_auto_examples_lor_machine.py` for an example of +using boolean algebra in conditions. +``` + ```{hint} In Python, a boolean value is either `True` or `False`. However, there are also specific values that are considered "**falsy**" and will evaluate as `False` when used in a boolean context. diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py index 43a022d9..3046b41d 100644 --- a/statemachine/callbacks.py +++ b/statemachine/callbacks.py @@ -5,19 +5,30 @@ from enum import IntEnum from enum import IntFlag from enum import auto +from functools import partial +from functools import reduce from inspect import isawaitable from inspect import iscoroutinefunction +from typing import TYPE_CHECKING from typing import Callable from typing import Dict from typing import Generator from typing import Iterable from typing import List +from typing import Set from typing import Type from .exceptions import AttrNotFound +from .exceptions import InvalidDefinition from .i18n import _ +from .spec_parser import custom_and +from .spec_parser import operator_mapping +from .spec_parser import parse_boolean_expr from .utils import ensure_iterable +if TYPE_CHECKING: + from statemachine.dispatcher import Listeners + class CallbackPriority(IntEnum): GENERIC = 0 @@ -54,6 +65,17 @@ def allways_true(*args, **kwargs): return True +def take_callback(name: str, resolver: "Listeners", not_found_handler: Callable) -> Callable: + callbacks = list(resolver.search_name(name)) + if len(callbacks) == 0: + not_found_handler(name) + return allways_true + elif len(callbacks) == 1: + return callbacks[0] + else: + return reduce(custom_and, callbacks) + + class CallbackSpec: """Specs about callbacks. @@ -110,7 +132,16 @@ def _update_func(self, func: Callable, attr_name: str): self.reference = SpecReference.CALLABLE self.attr_name = attr_name - def build(self, resolver) -> Generator["CallbackWrapper", None, None]: + def _wrap(self, callback): + condition = self.cond if self.cond is not None else allways_true + return CallbackWrapper( + callback=callback, + condition=condition, + meta=self, + unique_key=callback.unique_key, + ) + + def build(self, resolver: "Listeners") -> Generator["CallbackWrapper", None, None]: """ Resolves the `func` into a usable callable. @@ -118,14 +149,29 @@ def build(self, resolver) -> Generator["CallbackWrapper", None, None]: resolver (callable): A method responsible to build and return a valid callable that can receive arbitrary parameters like `*args, **kwargs`. """ - for callback in resolver.search(self): - condition = self.cond if self.cond is not None else allways_true - yield CallbackWrapper( - callback=callback, - condition=condition, - meta=self, - unique_key=callback.unique_key, + if ( + not self.is_convention + and self.group == CallbackGroup.COND + and self.reference == SpecReference.NAME + ): + names_not_found: Set[str] = set() + take_callback_partial = partial( + take_callback, resolver=resolver, not_found_handler=names_not_found.add ) + try: + expression = parse_boolean_expr(self.func, take_callback_partial, operator_mapping) + except SyntaxError as err: + raise InvalidDefinition( + _("Failed to parse boolean expression '{}'").format(self.func) + ) from err + if not expression or names_not_found: + self.names_not_found = names_not_found + return + yield self._wrap(expression) + return + + for callback in resolver.search(self): + yield self._wrap(callback) class SpecListGrouper: @@ -292,7 +338,7 @@ def __repr__(self): def __str__(self): return ", ".join(str(c) for c in self) - def _add(self, spec: CallbackSpec, resolver: Callable): + def _add(self, spec: CallbackSpec, resolver: "Listeners"): for callback in spec.build(resolver): if callback.unique_key in self.items_already_seen: continue @@ -300,7 +346,7 @@ def _add(self, spec: CallbackSpec, resolver: Callable): self.items_already_seen.add(callback.unique_key) insort(self.items, callback) - def add(self, items: Iterable[CallbackSpec], resolver: Callable): + def add(self, items: Iterable[CallbackSpec], resolver: "Listeners"): """Validate configurations""" for item in items: self._add(item, resolver) @@ -356,6 +402,12 @@ def check(self, specs: CallbackSpecList): callback for callback in self[meta.group.build_key(specs)] if callback.meta == meta ): continue + if hasattr(meta, "names_not_found"): + raise AttrNotFound( + _("Did not found name '{}' from model or statemachine").format( + ", ".join(meta.names_not_found) + ), + ) raise AttrNotFound( _("Did not found name '{}' from model or statemachine").format(meta.func) ) diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index 7ca1b55c..c6ad855d 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -75,7 +75,7 @@ def resolve( def search(self, spec: "CallbackSpec") -> Generator["Callable", None, None]: if spec.reference is SpecReference.NAME: - yield from self._search_name(spec.func) + yield from self.search_name(spec.func) return elif spec.reference is SpecReference.CALLABLE: yield self._search_callable(spec) @@ -111,7 +111,7 @@ def _search_callable(self, spec) -> "Callable": return callable_method(spec.attr_name, spec.func, None) - def _search_name(self, name) -> Generator["Callable", None, None]: + def search_name(self, name) -> Generator["Callable", None, None]: for config in self.items: if name not in config.all_attrs: continue @@ -143,6 +143,7 @@ def method(*args, **kwargs): return getter(obj) method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined] + method.__name__ = attribute return method diff --git a/statemachine/spec_parser.py b/statemachine/spec_parser.py new file mode 100644 index 00000000..7c824869 --- /dev/null +++ b/statemachine/spec_parser.py @@ -0,0 +1,79 @@ +import ast +import re +from typing import Callable + +replacements = {"!": "not ", "^": " and ", "v": " or "} + +pattern = re.compile(r"\!|\^|\bv\b") + + +def replace_operators(expr: str) -> str: + # preprocess the expression adding support for classical logical operators + def match_func(match): + return replacements[match.group(0)] + + return pattern.sub(match_func, expr) + + +def custom_not(predicate: Callable) -> Callable: + def decorated(*args, **kwargs) -> bool: + return not predicate(*args, **kwargs) + + decorated.__name__ = f"not({predicate.__name__})" + unique_key = getattr(predicate, "unique_key", "") + decorated.unique_key = f"not({unique_key})" # type: ignore[attr-defined] + return decorated + + +def _unique_key(left, right, operator) -> str: + left_key = getattr(left, "unique_key", "") + right_key = getattr(right, "unique_key", "") + return f"{left_key} {operator} {right_key}" + + +def custom_and(left: Callable, right: Callable) -> Callable: + def decorated(*args, **kwargs) -> bool: + return left(*args, **kwargs) and right(*args, **kwargs) # type: ignore[no-any-return] + + decorated.__name__ = f"({left.__name__} and {right.__name__})" + decorated.unique_key = _unique_key(left, right, "and") # type: ignore[attr-defined] + return decorated + + +def custom_or(left: Callable, right: Callable) -> Callable: + def decorated(*args, **kwargs) -> bool: + return left(*args, **kwargs) or right(*args, **kwargs) # type: ignore[no-any-return] + + decorated.__name__ = f"({left.__name__} or {right.__name__})" + decorated.unique_key = _unique_key(left, right, "or") # type: ignore[attr-defined] + return decorated + + +def build_expression(node, variable_hook, operator_mapping): + if isinstance(node, ast.BoolOp): + # Handle `and` / `or` operations + operator_fn = operator_mapping[type(node.op)] + left_expr = build_expression(node.values[0], variable_hook, operator_mapping) + for right in node.values[1:]: + right_expr = build_expression(right, variable_hook, operator_mapping) + left_expr = operator_fn(left_expr, right_expr) + return left_expr + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + # Handle `not` operation + operand_expr = build_expression(node.operand, variable_hook, operator_mapping) + return operator_mapping[type(node.op)](operand_expr) + elif isinstance(node, ast.Name): + # Handle variables by calling the variable_hook + return variable_hook(node.id) + else: + raise ValueError(f"Unsupported expression structure: {node.__class__.__name__}") + + +def parse_boolean_expr(expr, variable_hook, operator_mapping): + """Parses the expression into an AST and build a custom expression tree""" + expr = replace_operators(expr) + tree = ast.parse(expr, mode="eval") + return build_expression(tree.body, variable_hook, operator_mapping) + + +operator_mapping = {ast.Or: custom_or, ast.And: custom_and, ast.Not: custom_not} diff --git a/tests/examples/lor_machine.py b/tests/examples/lor_machine.py new file mode 100644 index 00000000..d262fa5f --- /dev/null +++ b/tests/examples/lor_machine.py @@ -0,0 +1,102 @@ +""" +Lord of the Rings Quest - Boolean algebra +========================================= + +Example that demonstrates the use of Boolean algebra in conditions. + +""" + +from statemachine import State +from statemachine import StateMachine +from statemachine.exceptions import TransitionNotAllowed + + +class LordOfTheRingsQuestStateMachine(StateMachine): + # Define the states + shire = State("In the Shire", initial=True) + bree = State("In Bree") + rivendell = State("At Rivendell") + moria = State("In Moria") + lothlorien = State("In Lothlorien") + mordor = State("In Mordor") + mount_doom = State("At Mount Doom", final=True) + + # Define transitions with Boolean conditions + start_journey = shire.to(bree, cond="frodo_has_ring and !sauron_alive") + meet_elves = bree.to(rivendell, cond="gandalf_present and frodo_has_ring") + enter_moria = rivendell.to(moria, cond="orc_army_nearby or frodo_has_ring") + reach_lothlorien = moria.to(lothlorien, cond="!orc_army_nearby") + journey_to_mordor = lothlorien.to(mordor, cond="frodo_has_ring and sam_is_loyal") + destroy_ring = mordor.to(mount_doom, cond="frodo_has_ring and frodo_resists_ring") + + # Conditions (attributes representing the state of conditions) + frodo_has_ring: bool = True + sauron_alive: bool = True # Initially, Sauron is alive + gandalf_present: bool = False # Gandalf is not present at the start + orc_army_nearby: bool = False + sam_is_loyal: bool = True + frodo_resists_ring: bool = False # Initially, Frodo is not resisting the ring + + +# %% +# Playing + +quest = LordOfTheRingsQuestStateMachine() + +# Track state changes +print(f"Current State: {quest.current_state.id}") # Should start at "shire" + +# Step 1: Start the journey +quest.sauron_alive = False # Assume Sauron is no longer alive +try: + quest.start_journey() + print(f"Current State: {quest.current_state.id}") # Should be "bree" +except TransitionNotAllowed: + print("Unable to start journey: conditions not met.") + +# Step 2: Meet the elves in Rivendell +quest.gandalf_present = True # Gandalf is now present +try: + quest.meet_elves() + print(f"Current State: {quest.current_state.id}") # Should be "rivendell" +except TransitionNotAllowed: + print("Unable to meet elves: conditions not met.") + +# Step 3: Enter Moria +quest.orc_army_nearby = True # Orc army is nearby +try: + quest.enter_moria() + print(f"Current State: {quest.current_state.id}") # Should be "moria" +except TransitionNotAllowed: + print("Unable to enter Moria: conditions not met.") + +# Step 4: Reach Lothlorien +quest.orc_army_nearby = False # Orcs are no longer nearby +try: + quest.reach_lothlorien() + print(f"Current State: {quest.current_state.id}") # Should be "lothlorien" +except TransitionNotAllowed: + print("Unable to reach Lothlorien: conditions not met.") + +# Step 5: Journey to Mordor +try: + quest.journey_to_mordor() + print(f"Current State: {quest.current_state.id}") # Should be "mordor" +except TransitionNotAllowed: + print("Unable to journey to Mordor: conditions not met.") + +# Step 6: Fight with Smeagol +try: + quest.destroy_ring() + print(f"Current State: {quest.current_state.id}") # Should be "mount_doom" +except TransitionNotAllowed: + print("Unable to destroy the ring: conditions not met.") + + +# Step 7: Destroy the ring at Mount Doom +quest.frodo_resists_ring = True # Frodo is now resisting the ring +try: + quest.destroy_ring() + print(f"Current State: {quest.current_state.id}") # Should be "mount_doom" +except TransitionNotAllowed: + print("Unable to destroy the ring: conditions not met.") diff --git a/tests/test_conditions_algebra.py b/tests/test_conditions_algebra.py new file mode 100644 index 00000000..e2d0f0cd --- /dev/null +++ b/tests/test_conditions_algebra.py @@ -0,0 +1,65 @@ +import pytest + +from statemachine import State +from statemachine import StateMachine +from statemachine.exceptions import InvalidDefinition + + +class AnyConditionSM(StateMachine): + start = State(initial=True) + end = State(final=True) + + submit = start.to(end, cond="used_money or used_credit") + + used_money: bool = False + used_credit: bool = False + + +def test_conditions_algebra_any_false(): + sm = AnyConditionSM() + with pytest.raises(sm.TransitionNotAllowed): + sm.submit() + + assert sm.current_state == sm.start + + +def test_conditions_algebra_any_left_true(): + sm = AnyConditionSM() + sm.used_money = True + sm.submit() + assert sm.current_state == sm.end + + +def test_conditions_algebra_any_right_true(): + sm = AnyConditionSM() + sm.used_credit = True + sm.submit() + assert sm.current_state == sm.end + + +def test_should_raise_invalid_definition_if_cond_is_not_valid_sintax(): + class AnyConditionSM(StateMachine): + start = State(initial=True) + end = State(final=True) + + submit = start.to(end, cond="used_money xxx") + + used_money: bool = False + used_credit: bool = False + + with pytest.raises(InvalidDefinition, match="Failed to parse boolean expression"): + AnyConditionSM() + + +def test_should_raise_invalid_definition_if_cond_is_not_found(): + class AnyConditionSM(StateMachine): + start = State(initial=True) + end = State(final=True) + + submit = start.to(end, cond="used_money and xxx") + + used_money: bool = False + used_credit: bool = False + + with pytest.raises(InvalidDefinition, match="Did not found name 'xxx'"): + AnyConditionSM() diff --git a/tests/test_spec_parser.py b/tests/test_spec_parser.py new file mode 100644 index 00000000..95dba090 --- /dev/null +++ b/tests/test_spec_parser.py @@ -0,0 +1,131 @@ +import pytest + +from statemachine.spec_parser import operator_mapping +from statemachine.spec_parser import parse_boolean_expr + + +def variable_hook(var_name): + values = { + "frodo_has_ring": True, + "sauron_alive": False, + "gandalf_present": True, + "sam_is_loyal": True, + "orc_army_ready": False, + } + + def decorated(*args, **kwargs): + return values.get(var_name, False) + + decorated.__name__ = var_name + return decorated + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("frodo_has_ring", True), + ("frodo_has_ring or sauron_alive", True), + ("frodo_has_ring and gandalf_present", True), + ("sauron_alive", False), + ("not sauron_alive", True), + ("frodo_has_ring and (gandalf_present or sauron_alive)", True), + ("not sauron_alive and orc_army_ready", False), + ("not (not sauron_alive and orc_army_ready)", True), + ("(frodo_has_ring and sam_is_loyal) or (not sauron_alive and orc_army_ready)", True), + ("(frodo_has_ring ^ sam_is_loyal) v (!sauron_alive ^ orc_army_ready)", True), + ("not (not frodo_has_ring)", True), + ("!(!frodo_has_ring)", True), + ("frodo_has_ring and orc_army_ready", False), + ("frodo_has_ring ^ orc_army_ready", False), + ("frodo_has_ring and not orc_army_ready", True), + ("frodo_has_ring ^ !orc_army_ready", True), + ("frodo_has_ring and (sam_is_loyal or (gandalf_present and not sauron_alive))", True), + ("frodo_has_ring ^ (sam_is_loyal v (gandalf_present ^ !sauron_alive))", True), + ("sauron_alive or orc_army_ready", False), + ("sauron_alive v orc_army_ready", False), + ("(frodo_has_ring and gandalf_present) or orc_army_ready", True), + ("orc_army_ready or (frodo_has_ring and gandalf_present)", True), + ("orc_army_ready and (frodo_has_ring and gandalf_present)", False), + ("!orc_army_ready and (frodo_has_ring and gandalf_present)", True), + ("!orc_army_ready and !(frodo_has_ring and gandalf_present)", False), + ], +) +def test_expressions(expression, expected): + parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping) + assert parsed_expr() is expected, expression + + +def test_negating_compound_false_expression(): + expr = "not (not sauron_alive and orc_army_ready)" + parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) + assert parsed_expr() is True + assert parsed_expr.__name__ == "not((not(sauron_alive) and orc_army_ready))" + + +def test_expression_name_uniqueness(): + expr = "frodo_has_ring or not orc_army_ready" + parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) + assert ( + parsed_expr.__name__ == "(frodo_has_ring or not(orc_army_ready))" + ) # name reflects expression structure + + +def test_classical_operators_name(): + expr = "frodo_has_ring ^ !orc_army_ready" + parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) + assert parsed_expr() is True # both parts are True + assert ( + parsed_expr.__name__ == "(frodo_has_ring and not(orc_army_ready))" + ) # name reflects expression structure + + +def test_empty_expression(): + expr = "" + with pytest.raises(SyntaxError): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_whitespace_expression(): + expr = " " + with pytest.raises(SyntaxError): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_missing_operator_expression(): + expr = "frodo_has_ring orc_army_ready" + with pytest.raises(SyntaxError): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_constant_usage_expression(): + expr = "frodo_has_ring or True" + with pytest.raises(ValueError, match="Unsupported expression structure"): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_dict_usage_expression(): + expr = "frodo_has_ring or {}" + with pytest.raises(ValueError, match="Unsupported expression structure"): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_unsupported_operator(): + # Define an unsupported operator like MUL + expr = "frodo_has_ring * gandalf_present" + with pytest.raises(ValueError, match="Unsupported expression structure"): + parse_boolean_expr(expr, variable_hook, operator_mapping) + + +def test_simple_variable_returns_the_original_callback(): + def original_callback(*args, **kwargs): + return True + + mapping = {"original": original_callback} + + def variable_hook(var_name): + return mapping.get(var_name, None) + + expr = "original" + parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) + + assert parsed_expr is original_callback From f22bae08e6f7e5f82936fe54c355f14d9e2c0f57 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 4 Nov 2024 20:14:28 -0300 Subject: [PATCH 3/6] feat: Improved Event class (#488) --- docs/api.md | 7 ++ docs/transitions.md | 168 +++++++++++++++++++++++++--- statemachine/__init__.py | 3 +- statemachine/dispatcher.py | 3 +- statemachine/event.py | 106 +++++++++++++----- statemachine/events.py | 21 +++- statemachine/factory.py | 59 ++++++++-- statemachine/statemachine.py | 27 ++--- statemachine/transition.py | 3 +- statemachine/transition_list.py | 6 +- tests/test_events.py | 191 +++++++++++++++++++++++++++++++- 11 files changed, 508 insertions(+), 86 deletions(-) diff --git a/docs/api.md b/docs/api.md index f838546e..6f2de0c5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -66,6 +66,13 @@ :members: ``` +## Event (class) + +```{eval-rst} +.. autoclass:: statemachine.event.Event + :members: +``` + ## EventData ```{eval-rst} diff --git a/docs/transitions.md b/docs/transitions.md index f0f84918..60e24d88 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -47,7 +47,7 @@ And these transitions are assigned to the {ref}`event` `cycle` defined at the cl ```{note} -In fact, before the full class body is evaluated, the assigments of transitions are instances of [](statemachine.transition_list.TransitionList). When the state machine is evaluated by our custom [metaclass](https://docs.python.org/3/reference/datamodel.html#metaclasses), these names will be transformed into a method that triggers an {ref}`Event`. +In fact, before the full class body is evaluated, the assigments of transitions are instances of [](statemachine.transition_list.TransitionList). When the state machine is evaluated by our custom [metaclass](https://docs.python.org/3/reference/datamodel.html#metaclasses), these names will be transformed into {ref}`Event` instances. ``` @@ -171,6 +171,153 @@ initiates a change in the state of the system. In `python-statemachine`, an event is specified as an attribute of the state machine class declaration or directly on the {ref}`event` parameter on a {ref}`transition`. + +### Declaring events + +The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the +State machine class level. The name will be converted to an {ref}`Event (class)`: + +```py +>>> from statemachine import Event + +>>> class SimpleSM(StateMachine): +... initial = State(initial=True) +... final = State() +... +... start = initial.to(final) # start is a name that will be converted to an `Event` + +>>> isinstance(SimpleSM.start, Event) +True +>>> sm = SimpleSM() +>>> sm.start() # call `start` event + +``` + +```{versionadded} 2.6.7 +You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings. +``` + +To declare an explicit event you must also import the {ref}`Event (class)`: + +```py +>>> from statemachine import Event + +>>> class SimpleSM(StateMachine): +... initial = State(initial=True) +... final = State() +... +... start = Event( +... initial.to(final), +... name="Start the state machine" # optional name, if not provided it will be derived from id +... ) + +>>> SimpleSM.start.name +'Start the state machine' + +>>> sm = SimpleSM() +>>> sm.start() # call `start` event + +``` + +An {ref}`Event (class)` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need. + +```py +>>> from statemachine import State, StateMachine, Event + +>>> class TrafficLightMachine(StateMachine): +... "A traffic light machine" +... +... green = State(initial=True) +... yellow = State() +... red = State() +... +... slowdown = Event(name="Slowing down") +... +... cycle = Event( +... green.to(yellow, event=slowdown) +... | yellow.to(red, event=Event("stop", name="Please stop!")) +... | red.to(green, event="go"), +... name="Loop", +... ) +... +... def on_transition(self, event_data, event: Event): +... # The `event` parameter can be declared as `str` or `Event`, since `Event` is a subclass of `str` +... # Note also that in this example, we're using `on_transition` instead of `on_cycle`, as this +... # binds the action to run for every transition instead of a specific event ID. +... assert event_data.event == event +... return ( +... f"Running {event.name} from {event_data.transition.source.id} to " +... f"{event_data.transition.target.id}" +... ) + +>>> # Event IDs +>>> TrafficLightMachine.cycle.id +'cycle' +>>> TrafficLightMachine.slowdown.id +'slowdown' +>>> TrafficLightMachine.stop.id +'stop' +>>> TrafficLightMachine.go.id +'go' + +>>> # Event names +>>> TrafficLightMachine.cycle.name +'Loop' +>>> TrafficLightMachine.slowdown.name +'Slowing down' +>>> TrafficLightMachine.stop.name +'Please stop!' +>>> TrafficLightMachine.go.name +'go' + +>>> sm = TrafficLightMachine() + +>>> sm.cycle() # Your IDE is happy because it now knows that `cycle` is callable! +'Running Loop from green to yellow' + +>>> sm.send("cycle") # You can also use `send` in order to process dynamic event sources +'Running Loop from yellow to red' + +>>> sm.send("cycle") +'Running Loop from red to green' + +>>> sm.send("slowdown") +'Running Slowing down from green to yellow' + +>>> sm.send("stop") +'Running Please stop! from yellow to red' + +>>> sm.send("go") +'Running go from red to green' + +``` + +```{tip} +Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event (class)` instances under the hood. + +``` + +```{note} +In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event (class)` inherits from `str`. + +Note that this is just an implementation detail and can change in the future. + +>>> isinstance(TrafficLightMachine.cycle, str) +True + +``` + + +```{warning} +An {ref}`Event` declared as string will have its `name` set equal to its `id`. This is for backward compatibility when migrating from previous versions. + +In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`). + +Starting from version 2.3.7, use `Event.id` to check for event identifiers instead of `Event.name`. + +``` + + ### Triggering events Triggering an event on a state machine means invoking or sending a signal, initiating the @@ -188,14 +335,13 @@ associated with the transition See {ref}`actions` and {ref}`validators and guards`. ``` - You can invoke the event in an imperative syntax: ```py >>> machine = TrafficLightMachine() >>> machine.cycle() -Running cycle from green to yellow +'Running Loop from green to yellow' >>> machine.current_state.id 'yellow' @@ -206,25 +352,13 @@ Or in an event-oriented style, events are `send`: ```py >>> machine.send("cycle") -Running cycle from yellow to red +'Running Loop from yellow to red' >>> machine.current_state.id 'red' ``` -You can also pass positional and keyword arguments, that will be propagated -to the actions and guards. In this example, the :code:`TrafficLightMachine` implements -an action that `echoes` back the parameters informed. - -```{literalinclude} ../tests/examples/traffic_light_machine.py - :language: python - :linenos: - :emphasize-lines: 10 - :lines: 12-21 -``` - - This action is executed before the transition associated with `cycle` event is activated. You can raise an exception at this point to stop a transition from completing. @@ -233,7 +367,7 @@ You can raise an exception at this point to stop a transition from completing. 'red' >>> machine.cycle() -Running cycle from red to green +'Running Loop from red to green' >>> machine.current_state.id 'green' diff --git a/statemachine/__init__.py b/statemachine/__init__.py index 75815537..a9002a3b 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -1,3 +1,4 @@ +from .event import Event from .state import State from .statemachine import StateMachine @@ -5,4 +6,4 @@ __email__ = "fgmacedo@gmail.com" __version__ = "2.3.6" -__all__ = ["StateMachine", "State"] +__all__ = ["StateMachine", "State", "Event"] diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index c6ad855d..51eb9e37 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -10,6 +10,7 @@ from .callbacks import SPECS_ALL from .callbacks import SpecReference +from .event import Event from .signature import SignatureAdapter if TYPE_CHECKING: @@ -121,7 +122,7 @@ def search_name(self, name) -> Generator["Callable", None, None]: yield attr_method(name, config.obj, config.resolver_id) continue - if getattr(func, "_is_sm_event", False): + if isinstance(func, Event): yield event_method(name, func, config.resolver_id) continue diff --git a/statemachine/event.py b/statemachine/event.py index b40df882..3c953450 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -1,5 +1,7 @@ from inspect import isawaitable from typing import TYPE_CHECKING +from typing import List +from uuid import uuid4 from statemachine.utils import run_async_from_sync @@ -7,6 +9,8 @@ if TYPE_CHECKING: from .statemachine import StateMachine + from .transition_list import TransitionList + _event_data_kwargs = { "event_data", @@ -20,46 +24,94 @@ } -class Event: - def __init__(self, name: str): - self.name: str = name +class Event(str): + id: str + """The event identifier.""" + + name: str + """The event name.""" + + _sm: "StateMachine | None" = None + """The state machine instance.""" + + _transitions: "TransitionList | None" = None + _has_real_id = False + + def __new__( + cls, + transitions: "str | TransitionList | None" = None, + id: "str | None" = None, + name: "str | None" = None, + _sm: "StateMachine | None" = None, + ): + if isinstance(transitions, str): + id = transitions + transitions = None + + _has_real_id = id is not None + id = str(id) if _has_real_id else f"__event__{uuid4().hex}" + + instance = super().__new__(cls, id) + instance.id = id + if name: + instance.name = name + elif _has_real_id: + instance.name = str(id).replace("_", " ").capitalize() + else: + instance.name = "" + if transitions: + instance._transitions = transitions + instance._has_real_id = _has_real_id + instance._sm = _sm + return instance def __repr__(self): - return f"{type(self).__name__}({self.name!r})" + return f"{type(self).__name__}({self.id!r})" + + def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool: + return self == event + + def __get__(self, instance, owner): + """By implementing this method `Event` can be used as a property descriptor + + When attached to a SM class, if the user tries to get the `Event` instance, + we intercept here and return a `BoundEvent` instance, so the user can call + it as a method with the correct SM instance. + + """ + if instance is None: + return self + return BoundEvent(id=self.id, name=self.name, _sm=instance) + + def __call__(self, *args, **kwargs): + """Send this event to the current state machine.""" + # The `__call__` is declared here to help IDEs knowing that an `Event` + # can be called as a method. But it is not meant to be called without + # an SM instance. Such SM instance is provided by `__get__` method when + # used as a property descriptor. - def trigger(self, machine: "StateMachine", *args, **kwargs): + machine = self._sm kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs} trigger_data = TriggerData( machine=machine, - event=self.name, + event=self, args=args, kwargs=kwargs, ) machine._put_nonblocking(trigger_data) - return machine._processing_loop() - - -def trigger_event_factory(event_instance: Event): - """Build a method that sends specific `event` to the machine""" - - def trigger_event(self, *args, **kwargs): - result = event_instance.trigger(self, *args, **kwargs) + result = machine._processing_loop() if not isawaitable(result): return result return run_async_from_sync(result) - trigger_event.name = event_instance.name # type: ignore[attr-defined] - trigger_event.identifier = event_instance.name # type: ignore[attr-defined] - trigger_event._is_sm_event = True # type: ignore[attr-defined] - return trigger_event - - -def same_event_cond_builder(expected_event: str): - """ - Builds a condition method that evaluates to ``True`` when the expected event is received. - """ + def split( # type: ignore[override] + self, sep: "str | None" = None, maxsplit: int = -1 + ) -> List["Event"]: + result = super().split(sep, maxsplit) + if len(result) == 1: + return [self] + return [Event(event) for event in result] - def cond(*args, event: "str | None" = None, **kwargs) -> bool: - return event == expected_event - return cond +class BoundEvent(Event): + pass diff --git a/statemachine/events.py b/statemachine/events.py index 7e11a248..d7a15c6f 100644 --- a/statemachine/events.py +++ b/statemachine/events.py @@ -1,3 +1,5 @@ +from statemachine.event import Event + from .utils import ensure_iterable @@ -5,14 +7,14 @@ class Events: """A collection of event names.""" def __init__(self): - self.items = [] + self._items: list[Event] = [] def __repr__(self): - sep = " " if len(self.items) > 1 else "" - return sep.join(item for item in self.items) + sep = " " if len(self._items) > 1 else "" + return sep.join(item for item in self._items) def __iter__(self): - return iter(self.items) + return iter(self._items) def add(self, events): if events is None: @@ -21,11 +23,18 @@ def add(self, events): unprepared = ensure_iterable(events) for events in unprepared: for event in events.split(" "): - if event in self.items: + if event in self._items: continue - self.items.append(event) + if isinstance(event, Event): + self._items.append(event) + else: + self._items.append(Event(id=event, name=event)) return self def match(self, event: str): return any(e == event for e in self) + + def _replace(self, old, new): + self._items.remove(old) + self._items.append(new) diff --git a/statemachine/factory.py b/statemachine/factory.py index 40e3db15..f8e4fcac 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -8,7 +8,6 @@ from . import registry from .event import Event -from .event import trigger_event_factory from .exceptions import InvalidDefinition from .graph import iterate_states_and_transitions from .graph import visit_connected_states @@ -38,11 +37,13 @@ def __init__( cls._abstract = True cls._strict_states = strict_states - cls._events: Dict[str, Event] = {} + cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates cls._protected_attrs: set = set() + cls._events_to_update: Dict[Event, Event | None] = {} cls.add_inherited(bases) cls.add_from_attributes(attrs) + cls._update_event_references() try: cls.initial_state: State = next(s for s in cls.states if s.initial) @@ -174,17 +175,26 @@ def add_inherited(cls, bases): cls.add_state(state.id, state) events = getattr(base, "_events", {}) - for event in events.values(): - cls.add_event(event.name) + for event in events: + cls.add_event(event=Event(id=event.id, name=event.name)) - def add_from_attributes(cls, attrs): + def add_from_attributes(cls, attrs): # noqa: C901 for key, value in sorted(attrs.items(), key=lambda pair: pair[0]): if isinstance(value, States): cls._add_states_from_dict(value) if isinstance(value, State): cls.add_state(key, value) elif isinstance(value, (Transition, TransitionList)): - cls.add_event(key, value) + cls.add_event(event=Event(transitions=value, id=key, name=key)) + elif isinstance(value, (Event,)): + cls.add_event( + event=Event( + transitions=value._transitions, + id=key, + name=value.name, + ), + old_event=value, + ) elif getattr(value, "_specs_to_update", None): cls._add_unbounded_callback(key, value) @@ -196,7 +206,7 @@ def _add_unbounded_callback(cls, attr_name, func): # if func is an event, the `attr_name` will be replaced by an event trigger, # so we'll also give the ``func`` a new unique name to be used by the callback # machinery. - cls.add_event(attr_name, func._transitions) + cls.add_event(event=Event(func._transitions, id=attr_name, name=attr_name)) attr_name = f"_{attr_name}_{uuid4().hex}" setattr(cls, attr_name, func) @@ -214,17 +224,42 @@ def add_state(cls, id, state: State): for event in state.transitions.unique_events: cls.add_event(event) - def add_event(cls, event, transitions=None): + def add_event( + cls, + event: Event, + old_event: "Event | None" = None, + ): + if not event._has_real_id: + if event not in cls._events_to_update: + cls._events_to_update[event] = None + return + + transitions = event._transitions if transitions is not None: transitions.add_event(event) if event not in cls._events: - event_instance = Event(event) - cls._events[event] = event_instance - setattr(cls, event, trigger_event_factory(event_instance)) + cls._events[event] = None + setattr(cls, event.id, event) + + if old_event is not None: + cls._events_to_update[old_event] = event return cls._events[event] + def _update_event_references(cls): + for old_event, new_event in cls._events_to_update.items(): + for state in cls.states: + for transition in state.transitions: + if transition._events.match(old_event): + if new_event is None: + raise InvalidDefinition( + _("An event in the '{}' has no id.").format(transition) + ) + transition.events._replace(old_event, new_event) + + cls._events_to_update = {} + @property def events(self): - return list(self._events.values()) + return list(self._events) diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index fb4e45a1..6f148ed7 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -18,7 +18,7 @@ from .dispatcher import Listeners from .engines.async_ import AsyncEngine from .engines.sync import SyncEngine -from .event import Event +from .event import BoundEvent from .event_data import TriggerData from .exceptions import InvalidDefinition from .exceptions import InvalidStateValue @@ -168,17 +168,16 @@ def bind_events_to(self, *targets): """Bind the state machine events to the target objects.""" for event in self.events: - trigger = getattr(self, event.name) + trigger = getattr(self, event) for target in targets: - if hasattr(target, event.name): + if hasattr(target, event): warnings.warn( - f"Attribute {event.name!r} already exists on {target!r}. " - f"Skipping binding.", + f"Attribute '{event}' already exists on {target!r}. Skipping binding.", UserWarning, stacklevel=2, ) continue - setattr(target, event.name, trigger) + setattr(target, event, trigger) def _add_listener(self, listeners: "Listeners", allowed_references: SpecReference = SPECS_ALL): register = partial( @@ -316,21 +315,13 @@ def send(self, event: str, *args, **kwargs): See: :ref:`triggering events`. """ - result = self._async_send(event, *args, **kwargs) + event_instance: BoundEvent = getattr( + self, event, BoundEvent(id=event, name=event, _sm=self) + ) + result = event_instance(*args, **kwargs) if not isawaitable(result): return result return run_async_from_sync(result) - def _async_send(self, event: str, *args, **kwargs): - """Send an :ref:`Event` to the state machine. - - .. seealso:: - - See: :ref:`triggering events`. - - """ - event_instance: Event = Event(event) - return event_instance.trigger(self, *args, **kwargs) - def _get_callbacks(self, key) -> CallbacksExecutor: return self._callbacks_registry[key] diff --git a/statemachine/transition.py b/statemachine/transition.py index 423c1242..dc5260d6 100644 --- a/statemachine/transition.py +++ b/statemachine/transition.py @@ -1,7 +1,6 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList -from .event import same_event_cond_builder from .events import Events from .exceptions import InvalidDefinition @@ -87,7 +86,7 @@ def _setup(self): on("on_transition", priority=CallbackPriority.GENERIC, is_convention=True) for event in self._events: - same_event_cond = same_event_cond_builder(event) + same_event_cond = event.is_same_event before( f"before_{event}", priority=CallbackPriority.NAMING, diff --git a/statemachine/transition_list.py b/statemachine/transition_list.py index 12f827c8..0557764d 100644 --- a/statemachine/transition_list.py +++ b/statemachine/transition_list.py @@ -1,3 +1,4 @@ +from typing import TYPE_CHECKING from typing import Callable from typing import Iterable from typing import List @@ -5,6 +6,9 @@ from .transition import Transition from .utils import ensure_iterable +if TYPE_CHECKING: + from .events import Event + class TransitionList: """A list-like container of :ref:`transitions` with callback functions.""" @@ -170,7 +174,7 @@ def add_event(self, event: str): transition.add_event(event) @property - def unique_events(self) -> List[str]: + def unique_events(self) -> List["Event"]: """ Returns a list of unique event names across all transitions in the :ref:`TransitionList` instance. diff --git a/tests/test_events.py b/tests/test_events.py index 19a63967..0b25625f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,5 +1,9 @@ +import pytest + from statemachine import State from statemachine import StateMachine +from statemachine.event import Event +from statemachine.exceptions import InvalidDefinition def test_assign_events_on_transitions(): @@ -10,7 +14,7 @@ class TrafficLightMachine(StateMachine): yellow = State() red = State() - green.to(yellow, event="cycle slowdown slowdown") + green.to(yellow, event="cycle slowdown") yellow.to(red, event="cycle stop") red.to(green, event="cycle go") @@ -26,3 +30,188 @@ def on_cycle(self, event_data, event: str): assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" + + +class TestExplicitEvent: + def test_accept_event_instance(self): + class StartMachine(StateMachine): + created = State(initial=True) + started = State(final=True) + + start = Event(created.to(started)) + + assert [e.id for e in StartMachine.events] == ["start"] + assert [e.name for e in StartMachine.events] == ["Start"] + assert StartMachine.start.name == "Start" + + sm = StartMachine() + sm.send("start") + assert sm.current_state == sm.started + + def test_accept_event_name(self): + class StartMachine(StateMachine): + created = State(initial=True) + started = State(final=True) + + start = Event(created.to(started), name="Launch the machine") + + assert [e.id for e in StartMachine.events] == ["start"] + assert [e.name for e in StartMachine.events] == ["Launch the machine"] + assert StartMachine.start.name == "Launch the machine" + + def test_derive_name_from_id(self): + class StartMachine(StateMachine): + created = State(initial=True) + started = State(final=True) + + launch_the_machine = Event(created.to(started)) + + assert [e.id for e in StartMachine.events] == ["launch_the_machine"] + assert [e.name for e in StartMachine.events] == ["Launch the machine"] + assert StartMachine.launch_the_machine.name == "Launch the machine" + + def test_raise_invalid_definition_if_event_name_cannot_be_derived(self): + with pytest.raises(InvalidDefinition, match="has no id"): + + class StartMachine(StateMachine): + created = State(initial=True) + started = State() + + launch = Event(created.to(started)) + + started.to.itself(event=Event()) # event id not defined + + def test_derive_from_id(self): + class StartMachine(StateMachine): + created = State(initial=True) + started = State() + + created.to(started, event=Event("launch_rocket")) + + assert StartMachine.launch_rocket.name == "Launch rocket" + + def test_of_passing_event_as_parameters(self): + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + cycle = Event(name="Loop") + slowdown = Event(name="slow down") + stop = Event(name="Please stop") + go = Event(name="Go! Go! Go!") + + green.to(yellow, event=[cycle, slowdown]) + yellow.to(red, event=[cycle, stop]) + red.to(green, event=[cycle, go]) + + def on_cycle(self, event_data, event: str): + assert event_data.event == event + return ( + f"Running {event} from {event_data.transition.source.id} to " + f"{event_data.transition.target.id}" + ) + + sm = TrafficLightMachine() + + assert sm.send("cycle") == "Running cycle from green to yellow" + assert sm.send("cycle") == "Running cycle from yellow to red" + assert sm.send("cycle") == "Running cycle from red to green" + assert sm.cycle.name == "Loop" + assert sm.slowdown.name == "slow down" + assert sm.stop.name == "Please stop" + assert sm.go.name == "Go! Go! Go!" + + def test_mixing_event_and_parameters(self): + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + cycle = Event( + green.to(yellow, event=Event("slowdown", name="Slow down")) + | yellow.to(red, event=Event("stop", name="Please stop!")) + | red.to(green, event=Event("go", name="Go! Go! Go!")), + name="Loop", + ) + + def on_cycle(self, event_data, event: str): + assert event_data.event == event + return ( + f"Running {event} from {event_data.transition.source.id} to " + f"{event_data.transition.target.id}" + ) + + sm = TrafficLightMachine() + + assert sm.send("cycle") == "Running cycle from green to yellow" + assert sm.send("cycle") == "Running cycle from yellow to red" + assert sm.send("cycle") == "Running cycle from red to green" + assert sm.cycle.name == "Loop" + assert sm.slowdown.name == "Slow down" + assert sm.stop.name == "Please stop!" + assert sm.go.name == "Go! Go! Go!" + + def test_name_derived_from_identifier(self): + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + cycle = Event(name="Loop") + slow_down = Event() + green.to(yellow, event=[cycle, slow_down]) + yellow.to(red, event=[cycle, "stop"]) + red.to(green, event=[cycle, "go"]) + + def on_cycle(self, event_data, event: str): + assert event_data.event == event + return ( + f"Running {event} from {event_data.transition.source.id} to " + f"{event_data.transition.target.id}" + ) + + sm = TrafficLightMachine() + + assert sm.send("cycle") == "Running cycle from green to yellow" + assert sm.send("cycle") == "Running cycle from yellow to red" + assert sm.send("cycle") == "Running cycle from red to green" + assert sm.cycle.name == "Loop" + assert sm.slow_down.name == "Slow down" + assert sm.stop.name == "stop" + assert sm.go.name == "go" + + def test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events(self): + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + green.to(yellow, event=Event("cycle slowdown", name="Will be ignored")) + yellow.to(red, event=Event("cycle stop", name="Will be ignored")) + red.to(green, event=Event("cycle go", name="Will be ignored")) + + def on_cycle(self, event_data, event: str): + assert event_data.event == event + return ( + f"Running {event} from {event_data.transition.source.id} to " + f"{event_data.transition.target.id}" + ) + + sm = TrafficLightMachine() + + assert sm.slowdown.name == "Slowdown" + assert sm.stop.name == "Stop" + assert sm.go.name == "Go" + + assert sm.send("cycle") == "Running cycle from green to yellow" + assert sm.send("cycle") == "Running cycle from yellow to red" + assert sm.send("cycle") == "Running cycle from red to green" From 9cadf84bc8e8ded8e5a7598dbab1b0cec9ccda2c Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 5 Nov 2024 12:37:40 -0300 Subject: [PATCH 4/6] docs: Improving docs for events --- README.md | 14 ++++---- docs/api.md | 4 +-- docs/async.md | 4 +-- docs/guards.md | 68 ++++++++++++++++++++++++++++++------ docs/installation.md | 8 ++++- docs/transitions.md | 18 +++++----- statemachine/event.py | 16 ++++++++- statemachine/event_data.py | 3 +- statemachine/state.py | 7 ++++ statemachine/statemachine.py | 2 +- statemachine/states.py | 4 +-- 11 files changed, 111 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f9a9c8a8..57547049 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ There's a lot more to cover, please take a look at our docs: https://python-statemachine.readthedocs.io. -## Contributing to the project +## Contributing * Star this project * Open an Issue @@ -385,17 +385,17 @@ https://python-statemachine.readthedocs.io. - If you found this project helpful, please consider giving it a star on GitHub. -- **Contribute code**: If you would like to contribute code to this project, please submit a pull +- **Contribute code**: If you would like to contribute code, please submit a pull request. For more information on how to contribute, please see our [contributing.md](contributing.md) file. -- **Report bugs**: If you find any bugs in this project, please report them by opening an issue +- **Report bugs**: If you find any bugs, please report them by opening an issue on our GitHub issue tracker. -- **Suggest features**: If you have a great idea for a new feature, please let us know by opening - an issue on our GitHub issue tracker. +- **Suggest features**: If you have an idea for a new feature, of feels something being harder than it should be, + please let us know by opening an issue on our GitHub issue tracker. -- **Documentation**: Help improve this project's documentation by submitting pull requests. +- **Documentation**: Help improve documentation by submitting pull requests. -- **Promote the project**: Help spread the word about this project by sharing it on social media, +- **Promote the project**: Help spread the word by sharing on social media, writing a blog post, or giving a talk about it. Tag me on Twitter [@fgmacedo](https://twitter.com/fgmacedo) so I can share it too! diff --git a/docs/api.md b/docs/api.md index 6f2de0c5..a35ef877 100644 --- a/docs/api.md +++ b/docs/api.md @@ -66,11 +66,11 @@ :members: ``` -## Event (class) +## Event ```{eval-rst} .. autoclass:: statemachine.event.Event - :members: + :members: id, name, __call__ ``` ## EventData diff --git a/docs/async.md b/docs/async.md index aeed7685..e8a7e74f 100644 --- a/docs/async.md +++ b/docs/async.md @@ -4,9 +4,9 @@ Support for async code was added! ``` -The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`event` triggers, while maintaining the same external API for both synchronous and asynchronous codebases. +The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases. -This is achieved through a new concept called "engine," an internal strategy pattern abstraction that manages transitions and callbacks. +This is achieved through a new concept called **engine**, an internal strategy pattern abstraction that manages transitions and callbacks. There are two engines, {ref}`SyncEngine` and {ref}`AsyncEngine`. diff --git a/docs/guards.md b/docs/guards.md index a361cc71..92f8a902 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -30,32 +30,78 @@ A condition is generally a boolean function, property, or attribute, and must no There are two variations of Guard clauses available: - cond -: A list of conditions, acting like predicates. A transition is only allowed to occur if +: A list of condition expressions, acting like predicates. A transition is only allowed to occur if all conditions evaluate to ``True``. -* Single condition: `cond="condition"` -* Multiple conditions: `cond=["condition1", "condition2"]` +* Single condition expression: `cond="condition"` / `cond=""` +* Multiple condition expressions: `cond=["condition1", "condition2"]` unless : Same as `cond`, but the transition is only allowed if all conditions evaluate to ``False``. -* Single condition: `unless="condition"` +* Single condition: `unless="condition"` / `unless=""` * Multiple conditions: `unless=["condition1", "condition2"]` -Conditions also support [Boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra) expressions, allowing you to use compound logic within transition guards. You can use both standard Python logical operators (`not`, `and`, `or`) as well as classic Boolean algebra symbols: +### Condition expressions + +This library supports a mini-language for boolean expressions in conditions, allowing the definition of guards that control transitions based on specified criteria. It includes basic [boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra) operators, parentheses for controlling precedence, and **names** that refer to attributes on the state machine, its associated model, or registered {ref}`Listeners`. + +```{tip} +All condition expressions are evaluated when the State Machine is instantiated. This is by design to help you catch any invalid definitions early, rather than when your state machine is running. +``` + +The mini-language is based on Python's built-in language and the [`ast`](https://docs.python.org/3/library/ast.html) parser, so there are no surprises if you’re familiar with Python. Below is a formal specification to clarify the structure. + +#### Syntax elements + +1. **Names**: + - Names refer to attributes on the state machine instance, its model or listeners, used directly in expressions to evaluate conditions. + - Names must consist of alphanumeric characters and underscores (`_`) and cannot begin with a digit (e.g., `is_active`, `count`, `has_permission`). + - Any property name used in the expression must exist as an attribute on the state machine, model instance, or listeners, otherwise, an `InvalidDefinition` error is raised. + - Names can be pointed to `properties`, `attributes` or `methods`. If pointed to `attributes`, the library will create a + wrapper get method so each time the expression is evaluated the current value will be retrieved. + +2. **Boolean operators and precedence**: + - The following Boolean operators are supported, listed from highest to lowest precedence: + 1. `not` / `!` — Logical negation + 2. `and` / `^` — Logical conjunction + 3. `or` / `v` — Logical disjunction + - These operators are case-sensitive (e.g., `NOT` and `Not` are not equivalent to `not` and will raise syntax errors). + - Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent. -- `!` for `not` -- `^` for `and` -- `v` for `or` +3. **Parentheses for precedence**: + - When operators with the same precedence appear in the expression, evaluation proceeds from left to right, unless parentheses specify a different order. + - Parentheses `(` and `)` are supported to control the order of evaluation in expressions. + - Expressions within parentheses are evaluated first, allowing explicit precedence control (e.g., `(is_admin or is_moderator) and has_permission`). -For example: +#### Expression Examples + +Examples of valid boolean expressions include: +- `is_logged_in and has_permission` +- `not is_active or is_admin` +- `!(is_guest ^ has_access)` +- `(is_admin or is_moderator) and !is_banned` +- `has_account and (verified or trusted)` +- `frodo_has_ring and gandalf_present or !sauron_alive` + +Being used on a transition definition: ```python start.to(end, cond="frodo_has_ring and gandalf_present or !sauron_alive") ``` -Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent. +#### Summary of grammar rules +The mini-language is formally specified as follows: + +``` +Name: [A-Za-z_][A-Za-z0-9_]* +Boolean Expression: + + ::= | 'or' | 'v' + ::= | 'and' | '^' + ::= 'not' | '!' | '(' ')' | + +``` ```{seealso} See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of diff --git a/docs/installation.md b/docs/installation.md index bdf59f93..480bd831 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,13 @@ ## Latest release -To install Python State Machine using [poetry](https://python-poetry.org/): +To install using [uv](https://docs.astral.sh/uv): + +```shell +uv add python-statemachine +``` + +To install using [poetry](https://python-poetry.org/): ```shell poetry add python-statemachine diff --git a/docs/transitions.md b/docs/transitions.md index 60e24d88..e6ae0a28 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -160,8 +160,7 @@ the event name is used to describe the transition. ``` - -## Event +## Events An event is an external signal that something has happened. They are send to a state machine and allow the state machine to react. @@ -175,7 +174,7 @@ In `python-statemachine`, an event is specified as an attribute of the state mac ### Declaring events The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the -State machine class level. The name will be converted to an {ref}`Event (class)`: +State machine class level. The name will be converted to an {ref}`Event`: ```py >>> from statemachine import Event @@ -197,7 +196,7 @@ True You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings. ``` -To declare an explicit event you must also import the {ref}`Event (class)`: +To declare an explicit event you must also import the {ref}`Event`: ```py >>> from statemachine import Event @@ -219,7 +218,7 @@ To declare an explicit event you must also import the {ref}`Event (class)`: ``` -An {ref}`Event (class)` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need. +An {ref}`Event` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need. ```py >>> from statemachine import State, StateMachine, Event @@ -293,22 +292,23 @@ An {ref}`Event (class)` instance or an event id string can also be used as the ` ``` ```{tip} -Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event (class)` instances under the hood. +Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event` instances under the hood. ``` ```{note} -In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event (class)` inherits from `str`. +In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event` inherits from `str`. Note that this is just an implementation detail and can change in the future. ->>> isinstance(TrafficLightMachine.cycle, str) -True + >>> isinstance(TrafficLightMachine.cycle, str) + True ``` ```{warning} + An {ref}`Event` declared as string will have its `name` set equal to its `id`. This is for backward compatibility when migrating from previous versions. In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`). diff --git a/statemachine/event.py b/statemachine/event.py index 3c953450..d1dddcef 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -25,6 +25,16 @@ class Event(str): + """An event is triggers a signal that something has happened. + + They are send to a state machine and allow the state machine to react. + + An event starts a :ref:`Transition`, which can be thought of as a “cause” that initiates a + change in the state of the system. + + See also :ref:`events`. + """ + id: str """The event identifier.""" @@ -84,7 +94,11 @@ def __get__(self, instance, owner): return BoundEvent(id=self.id, name=self.name, _sm=instance) def __call__(self, *args, **kwargs): - """Send this event to the current state machine.""" + """Send this event to the current state machine. + + Triggering an event on a state machine means invoking or sending a signal, initiating the + process that may result in executing a transition. + """ # The `__call__` is declared here to help IDEs knowing that an `Event` # can be called as a method. But it is not meant to be called without # an SM instance. Such SM instance is provided by `__get__` method when diff --git a/statemachine/event_data.py b/statemachine/event_data.py index 93055803..00eaa65e 100644 --- a/statemachine/event_data.py +++ b/statemachine/event_data.py @@ -4,6 +4,7 @@ from typing import Any if TYPE_CHECKING: + from .event import Event from .state import State from .statemachine import StateMachine from .transition import Transition @@ -13,7 +14,7 @@ class TriggerData: machine: "StateMachine" - event: str + event: "Event" """The Event that was triggered.""" model: Any = field(init=False) diff --git a/statemachine/state.py b/statemachine/state.py index 4f67d5cd..076c11d3 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -92,6 +92,13 @@ class State: >>> [(t.source.name, t.target.name) for t in transitions] [('Draft', 'Draft'), ('Draft', 'Producing'), ('Draft', 'Closed')] + Sometimes it's easier to use the :func:`State.from_` method: + + >>> transitions = closed.from_(draft, producing, closed) + + >>> [(t.source.name, t.target.name) for t in transitions] + [('Draft', 'Closed'), ('Producing', 'Closed'), ('Closed', 'Closed')] + """ def __init__( diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 6f148ed7..253b996d 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -101,7 +101,7 @@ def __init__( if self.current_state_value is None: trigger_data = TriggerData( machine=self, - event="__initial__", + event=BoundEvent("__initial__", _sm=self), ) self._put_nonblocking(trigger_data) diff --git a/statemachine/states.py b/statemachine/states.py index 1f5c2257..cf71bacb 100644 --- a/statemachine/states.py +++ b/statemachine/states.py @@ -136,14 +136,14 @@ def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance: .. deprecated:: 2.3.3 - On the next major release, the ``use_enum_instance=True`` will be the default. + On the next major release, ``use_enum_instance=True`` will be the default. Args: enum_type: An enumeration containing the states of the machine. initial: The initial state of the machine. final: A set of final states of the machine. use_enum_instance: If ``True``, the value of the state will be the enum item instance, - otherwise the enum item value. + otherwise the enum item value. Defaults to ``False``. Returns: A new instance of the :ref:`States (class)`. From 5bd27b763c16646f3218ca2ab9cff0ba449a6fb7 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 5 Nov 2024 13:51:05 -0300 Subject: [PATCH 5/6] release: New 2.4.0 release doc --- docs/actions.md | 2 +- docs/guards.md | 8 +- docs/releases/2.4.0.md | 89 +++++++++++++++++++ docs/releases/index.md | 1 + docs/transitions.md | 2 +- pyproject.toml | 2 +- statemachine/__init__.py | 2 +- statemachine/exceptions.py | 5 +- .../async_guess_the_number_machine.py | 4 +- 9 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 docs/releases/2.4.0.md diff --git a/docs/actions.md b/docs/actions.md index 83ed3afd..ef8844be 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -160,7 +160,7 @@ It's also possible to use an event name as action. ## Transition actions -For each {ref}`event`, you can register `before`, `on`, and `after` callbacks. +For each {ref}`events`, you can register `before`, `on`, and `after` callbacks. ### Declare transition actions by naming convention diff --git a/docs/guards.md b/docs/guards.md index 92f8a902..241eb698 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -104,13 +104,13 @@ Boolean Expression: ``` ```{seealso} -See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of -combining multiple transitions to the same event. +See {ref}`sphx_glr_auto_examples_lor_machine.py` for an example of +using boolean algebra in conditions. ``` ```{seealso} -See {ref}`sphx_glr_auto_examples_lor_machine.py` for an example of -using boolean algebra in conditions. +See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of +combining multiple transitions to the same event. ``` ```{hint} diff --git a/docs/releases/2.4.0.md b/docs/releases/2.4.0.md new file mode 100644 index 00000000..d2f776bb --- /dev/null +++ b/docs/releases/2.4.0.md @@ -0,0 +1,89 @@ +# StateMachine 2.4.0 + +*November 5, 2024* + +## What's new in 2.4.0 + +This release introduces powerful new features for the `StateMachine` library: {ref}`Condition expressions` and explicit definition of {ref}`Events`. These updates make it easier to define complex transition conditions and enhance performance, especially in workflows with nested or recursive event structures. + +### Python compatibility in 2.4.0 + +StateMachine 2.4.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. + +### Conditions expressions in 2.4.0 + +This release introduces support for conditionals with Boolean algebra. You can now use expressions like `or`, `and`, and `not` directly within transition conditions, simplifying the definition of complex state transitions. This allows for more flexible and readable condition setups in your state machine configurations. + +Example (with a spoiler of the next highlight): + +```py +>>> from statemachine import StateMachine, State, Event + +>>> class AnyConditionSM(StateMachine): +... start = State(initial=True) +... end = State(final=True) +... +... submit = Event( +... start.to(end, cond="used_money or used_credit"), +... name="finish order", +... ) +... +... used_money: bool = False +... used_credit: bool = False + +>>> sm = AnyConditionSM() +>>> sm.submit() +Traceback (most recent call last): +TransitionNotAllowed: Can't finish order when in Start. + +>>> sm.used_credit = True +>>> sm.submit() +>>> sm.current_state.id +'end' + +``` + +```{seealso} +See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example. +``` + +### Explicit event creation in 2.4.0 + +Now you can explicit declare {ref}`Events` using the {ref}`event` class. This allows custom naming, translations, and also helps your IDE to know that events are callable. + +```py +>>> from statemachine import StateMachine, State, Event + +>>> class StartMachine(StateMachine): +... created = State(initial=True) +... started = State(final=True) +... +... start = Event(created.to(started), name="Launch the machine") +... +>>> [e.id for e in StartMachine.events] +['start'] +>>> [e.name for e in StartMachine.events] +['Launch the machine'] +>>> StartMachine.start.name +'Launch the machine' + +``` + +```{seealso} +See {ref}`Events` for more details. +``` + +### Recursive state machines (infinite loop) + +We removed a note from the docs saying to avoid recursion loops. Since the {ref}`StateMachine 2.0.0` release we've turned the RTC model enabled by default, allowing nested events to occour as all events are put on an internal queue before being executed. + +```{seealso} +See {ref}`sphx_glr_auto_examples_recursive_event_machine.py` for an example of an infinite loop state machine declaration using `after` action callback to call the same event over and over again. + +``` + + +## Bugfixes in 2.4.0 + +- Fixes [#484](https://github.com/fgmacedo/python-statemachine/issues/484) issue where nested events inside loops could leak memory by incorrectly + referencing previous `event_data` when queuing the next event. This fix improves performance and stability in event-heavy workflows. diff --git a/docs/releases/index.md b/docs/releases/index.md index 7abc84d9..d2c94262 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases. ```{toctree} :maxdepth: 2 +2.4.0 2.3.6 2.3.5 2.3.4 diff --git a/docs/transitions.md b/docs/transitions.md index e6ae0a28..293d9b39 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -313,7 +313,7 @@ An {ref}`Event` declared as string will have its `name` set equal to its `id`. T In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`). -Starting from version 2.3.7, use `Event.id` to check for event identifiers instead of `Event.name`. +Starting from version 2.4.0, use `Event.id` to check for event identifiers instead of `Event.name`. ``` diff --git a/pyproject.toml b/pyproject.toml index 54258e9d..b383289a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-statemachine" -version = "2.3.6" +version = "2.4.0" description = "Python Finite State Machines made easy." authors = ["Fernando Macedo "] maintainers = ["Fernando Macedo "] diff --git a/statemachine/__init__.py b/statemachine/__init__.py index a9002a3b..6148e85b 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -4,6 +4,6 @@ __author__ = """Fernando Macedo""" __email__ = "fgmacedo@gmail.com" -__version__ = "2.3.6" +__version__ = "2.4.0" __all__ = ["StateMachine", "State", "Event"] diff --git a/statemachine/exceptions.py b/statemachine/exceptions.py index 6b641605..f91daddc 100644 --- a/statemachine/exceptions.py +++ b/statemachine/exceptions.py @@ -3,6 +3,7 @@ from .i18n import _ if TYPE_CHECKING: + from .event import Event from .state import State @@ -31,8 +32,8 @@ class AttrNotFound(InvalidDefinition): class TransitionNotAllowed(StateMachineError): "Raised when there's no transition that can run from the current :ref:`state`." - def __init__(self, event: str, state: "State"): + def __init__(self, event: "Event", state: "State"): self.event = event self.state = state - msg = _("Can't {} when in {}.").format(self.event, self.state.name) + msg = _("Can't {} when in {}.").format(self.event.name, self.state.name) super().__init__(msg) diff --git a/tests/examples/async_guess_the_number_machine.py b/tests/examples/async_guess_the_number_machine.py index d6162f3d..9185fc88 100644 --- a/tests/examples/async_guess_the_number_machine.py +++ b/tests/examples/async_guess_the_number_machine.py @@ -130,8 +130,8 @@ async def connect_stdin_stdout(): # %% -# Executing -# --------- +# Executing the game +# ------------------ # # This script only run by passing the `-i` flag, avoiding blocking while running automated tests. # From 90b78fce90a7dd96d1987e97133296ec55a2888f Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 5 Nov 2024 14:08:55 -0300 Subject: [PATCH 6/6] release: Update translations --- .../locale/en/LC_MESSAGES/statemachine.po | 42 ++++++--- .../locale/hi_IN/LC_MESSAGES/statemachine.po | 93 +++++++++++++++++++ .../locale/pt_BR/LC_MESSAGES/statemachine.po | 70 +++++++------- .../locale/zh_CN/LC_MESSAGES/statemachine.po | 93 +++++++++++++++++++ 4 files changed, 249 insertions(+), 49 deletions(-) create mode 100644 statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po create mode 100644 statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po diff --git a/statemachine/locale/en/LC_MESSAGES/statemachine.po b/statemachine/locale/en/LC_MESSAGES/statemachine.po index 1e46c88f..39dca20a 100644 --- a/statemachine/locale/en/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/en/LC_MESSAGES/statemachine.po @@ -3,7 +3,7 @@ # msgid "" msgstr "" -"Project-Id-Version: 2.3.0\n" +"Project-Id-Version: 2.4.0\n" "Report-Msgid-Bugs-To: fgmacedo@gmail.com\n" "POT-Creation-Date: 2023-03-04 16:10-0300\n" "PO-Revision-Date: 2024-06-07 17:41-0300\n" @@ -13,68 +13,80 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.12.1\n" -#: statemachine/callbacks.py:289 +#: statemachine/callbacks.py:165 +msgid "Failed to parse boolean expression '{}'" +msgstr "" + +#: statemachine/callbacks.py:407 statemachine/callbacks.py:412 msgid "Did not found name '{}' from model or statemachine" msgstr "" -#: statemachine/exceptions.py:23 +#: statemachine/exceptions.py:24 msgid "{!r} is not a valid state value." msgstr "" -#: statemachine/exceptions.py:37 +#: statemachine/exceptions.py:38 msgid "Can't {} when in {}." msgstr "" -#: statemachine/factory.py:73 +#: statemachine/factory.py:74 msgid "There are no states." msgstr "" -#: statemachine/factory.py:76 +#: statemachine/factory.py:77 msgid "There are no events." msgstr "" -#: statemachine/factory.py:88 +#: statemachine/factory.py:89 msgid "" "There should be one and only one initial state. You currently have these:" " {!r}" msgstr "" -#: statemachine/factory.py:101 +#: statemachine/factory.py:102 msgid "Cannot declare transitions from final state. Invalid state(s): {}" msgstr "" -#: statemachine/factory.py:109 +#: statemachine/factory.py:110 msgid "" "All non-final states should have at least one outgoing transition. These " "states have no outgoing transition: {!r}" msgstr "" -#: statemachine/factory.py:123 +#: statemachine/factory.py:124 msgid "" "All non-final states should have at least one path to a final state. " "These states have no path to a final state: {!r}" msgstr "" -#: statemachine/factory.py:147 +#: statemachine/factory.py:148 msgid "" "There are unreachable states. The statemachine graph should have a single" " component. Disconnected states: {}" msgstr "" -#: statemachine/mixins.py:23 +#: statemachine/factory.py:257 +msgid "An event in the '{}' has no id." +msgstr "" + +#: statemachine/mixins.py:26 msgid "{!r} is not a valid state machine name." msgstr "" -#: statemachine/state.py:152 +#: statemachine/state.py:155 msgid "State overriding is not allowed. Trying to add '{}' to {}" msgstr "" -#: statemachine/statemachine.py:86 +#: statemachine/statemachine.py:94 msgid "There are no states or transitions." msgstr "" -#: statemachine/statemachine.py:249 +#: statemachine/statemachine.py:285 msgid "" "There's no current state set. In async code, did you activate the initial" " state? (e.g., `await sm.activate_initial_state()`)" msgstr "" + +#: statemachine/engines/async_.py:22 +msgid "Only RTC is supported on async engine" +msgstr "" diff --git a/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po b/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po new file mode 100644 index 00000000..5d437de6 --- /dev/null +++ b/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po @@ -0,0 +1,93 @@ +# This file is distributed under the same license as the project. +# Fernando Macedo , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: 2.4.0\n" +"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n" +"POT-Creation-Date: 2023-03-04 16:10-0300\n" +"PO-Revision-Date: 2024-06-07 17:41-0300\n" +"Last-Translator: Fernando Macedo \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + +#: statemachine/callbacks.py:165 +msgid "Failed to parse boolean expression '{}'" +msgstr "बूलियन अभिव्यक्ति '{}' को पार्स करने में विफल रहा" + +#: statemachine/callbacks.py:407 statemachine/callbacks.py:412 +msgid "Did not found name '{}' from model or statemachine" +msgstr "मॉडल या स्टेटमशीन में नाम '{}' नहीं मिला" + +#: statemachine/exceptions.py:24 +msgid "{!r} is not a valid state value." +msgstr "{!r} एक मान्य स्टेट मान नहीं है।" + +#: statemachine/exceptions.py:38 +msgid "Can't {} when in {}." +msgstr "{} स्थिति में {} नहीं कर सकते।" + +#: statemachine/factory.py:74 +msgid "There are no states." +msgstr "कोई स्टेट नहीं है।" + +#: statemachine/factory.py:77 +msgid "There are no events." +msgstr "कोई इवेंट नहीं है।" + +#: statemachine/factory.py:89 +msgid "" +"There should be one and only one initial state. You currently have these:" +" {!r}" +msgstr "एक और केवल एक प्रारंभिक स्टेट होना चाहिए। वर्तमान में आपके पास ये हैं: {!r}" + +#: statemachine/factory.py:102 +msgid "Cannot declare transitions from final state. Invalid state(s): {}" +msgstr "अंतिम स्टेट से ट्रांज़िशन घोषित नहीं कर सकते। अमान्य स्टेट: {}" + +#: statemachine/factory.py:110 +msgid "" +"All non-final states should have at least one outgoing transition. These " +"states have no outgoing transition: {!r}" +msgstr "सभी गैर-अंतिम स्टेट में कम से कम एक आउटगोइंग ट्रांज़िशन होना चाहिए। इन स्टेट में कोई आउटगोइंग ट्रांज़िशन नहीं है: {!r}" + +#: statemachine/factory.py:124 +msgid "" +"All non-final states should have at least one path to a final state. " +"These states have no path to a final state: {!r}" +msgstr "सभी गैर-अंतिम स्टेट में अंतिम स्टेट तक कम से कम एक पथ होना चाहिए। इन स्टेट में अंतिम स्टेट तक कोई पथ नहीं है: {!r}" + +#: statemachine/factory.py:148 +msgid "" +"There are unreachable states. The statemachine graph should have a single" +" component. Disconnected states: {}" +msgstr "कुछ स्टेट पहुंच योग्य नहीं हैं। स्टेटमशीन ग्राफ में एक ही घटक होना चाहिए। डिस्कनेक्टेड स्टेट: {}" + +#: statemachine/factory.py:257 +msgid "An event in the '{}' has no id." +msgstr "'{}' में एक इवेंट का आईडी नहीं है।" + +#: statemachine/mixins.py:26 +msgid "{!r} is not a valid state machine name." +msgstr "{!r} एक मान्य स्टेटमशीन नाम नहीं है।" + +#: statemachine/state.py:155 +msgid "State overriding is not allowed. Trying to add '{}' to {}" +msgstr "स्टेट ओवरराइड करना अनुमति नहीं है। '{}' को {} में जोड़ने की कोशिश कर रहे हैं" + +#: statemachine/statemachine.py:94 +msgid "There are no states or transitions." +msgstr "कोई स्टेट या ट्रांज़िशन नहीं हैं।" + +#: statemachine/statemachine.py:285 +msgid "" +"There's no current state set. In async code, did you activate the initial" +" state? (e.g., `await sm.activate_initial_state()`)" +msgstr "कोई वर्तमान स्टेट सेट नहीं है। असिंक्रोनस कोड में, क्या आपने प्रारंभिक स्टेट को सक्रिय किया? (उदाहरण: `await sm.activate_initial_state()`)" + +#: statemachine/engines/async_.py:22 +msgid "Only RTC is supported on async engine" +msgstr "असिंक्रोनस इंजन पर केवल RTC समर्थित है" diff --git a/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po b/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po index 5871f798..245d768e 100644 --- a/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po @@ -3,7 +3,7 @@ # msgid "" msgstr "" -"Project-Id-Version: 2.3.0\n" +"Project-Id-Version: 2.4.0\n" "Report-Msgid-Bugs-To: fgmacedo@gmail.com\n" "POT-Creation-Date: 2023-03-04 16:10-0300\n" "PO-Revision-Date: 2024-06-07 17:41-0300\n" @@ -14,78 +14,80 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.14.0\n" -#: statemachine/callbacks.py:289 +#: statemachine/callbacks.py:165 +msgid "Failed to parse boolean expression '{}'" +msgstr "Falha ao interpretar a expressão booleana '{}'" + +#: statemachine/callbacks.py:407 statemachine/callbacks.py:412 msgid "Did not found name '{}' from model or statemachine" -msgstr "Não encontrou o nome '{}' no modelo ou na máquina de estados" +msgstr "Nome '{}' não encontrado no modelo ou na máquina de estados" -#: statemachine/exceptions.py:23 +#: statemachine/exceptions.py:24 msgid "{!r} is not a valid state value." msgstr "{!r} não é um valor de estado válido." -#: statemachine/exceptions.py:37 +#: statemachine/exceptions.py:38 msgid "Can't {} when in {}." -msgstr "Não é possível {} quando está em {}." +msgstr "Não é possível {} quando em {}." -#: statemachine/factory.py:73 +#: statemachine/factory.py:74 msgid "There are no states." msgstr "Não há estados." -#: statemachine/factory.py:76 +#: statemachine/factory.py:77 msgid "There are no events." msgstr "Não há eventos." -#: statemachine/factory.py:88 +#: statemachine/factory.py:89 msgid "" "There should be one and only one initial state. You currently have these:" " {!r}" -msgstr "Deve haver um e apenas um estado inicial. Você atualmente tem estes: {!r}" +msgstr "Deve haver um e apenas um estado inicial. Atualmente, você possui estes: {!r}" -#: statemachine/factory.py:101 +#: statemachine/factory.py:102 msgid "Cannot declare transitions from final state. Invalid state(s): {}" -msgstr "" -"Não é possível declarar transições a partir do estado final. Estado(s) " -"inválido(s): {}" +msgstr "Não é possível declarar transições a partir de um estado final. Estado(s) inválido(s): {}" -#: statemachine/factory.py:109 +#: statemachine/factory.py:110 msgid "" "All non-final states should have at least one outgoing transition. These " "states have no outgoing transition: {!r}" -msgstr "" -"Todos os estados não finais devem ter pelo menos uma transição de saída. " -"Esses estados não têm transição de saída: {!r}" +msgstr "Todos os estados não finais devem ter pelo menos uma transição de saída. Estes estados não possuem transição de saída: {!r}" -#: statemachine/factory.py:123 +#: statemachine/factory.py:124 msgid "" "All non-final states should have at least one path to a final state. " "These states have no path to a final state: {!r}" -msgstr "" -"Todos os estados não finais devem ter pelo menos um caminho para um " -"estado final. Esses estados não têm caminho para um estado final: {!r}" +msgstr "Todos os estados não finais devem ter pelo menos um caminho para um estado final. Estes estados não possuem caminho para um estado final: {!r}" -#: statemachine/factory.py:147 +#: statemachine/factory.py:148 msgid "" "There are unreachable states. The statemachine graph should have a single" " component. Disconnected states: {}" -msgstr "" -"Há estados inacessíveis. O gráfico da máquina de estados deve ter um " -"único componente. Estados desconectados: {}" +msgstr "Há estados inacessíveis. O grafo da máquina de estados deve ter um único componente. Estados desconectados: {}" + +#: statemachine/factory.py:257 +msgid "An event in the '{}' has no id." +msgstr "Um evento em '{}' não possui id." -#: statemachine/mixins.py:23 +#: statemachine/mixins.py:26 msgid "{!r} is not a valid state machine name." -msgstr "{!r} não é um nome válido para uma máquina de estados." +msgstr "{!r} não é um nome de máquina de estados válido." -#: statemachine/state.py:152 +#: statemachine/state.py:155 msgid "State overriding is not allowed. Trying to add '{}' to {}" msgstr "Sobrescrever estados não é permitido. Tentando adicionar '{}' a {}" -#: statemachine/statemachine.py:86 +#: statemachine/statemachine.py:94 msgid "There are no states or transitions." msgstr "Não há estados ou transições." -#: statemachine/statemachine.py:249 +#: statemachine/statemachine.py:285 msgid "" "There's no current state set. In async code, did you activate the initial" " state? (e.g., `await sm.activate_initial_state()`)" -msgstr "" -"Não há estado atual definido. No código assíncrono, você ativou o estado" -" inicial? (por exemplo, `await sm.activate_initial_state()`)" +msgstr "Nenhum estado atual definido. Em código assíncrono, você ativou o estado inicial? (ex.: `await sm.activate_initial_state()`)" + +#: statemachine/engines/async_.py:22 +msgid "Only RTC is supported on async engine" +msgstr "Apenas RTC é suportado no motor assíncrono" diff --git a/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po b/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po new file mode 100644 index 00000000..5c23e861 --- /dev/null +++ b/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po @@ -0,0 +1,93 @@ +# This file is distributed under the same license as the project. +# Fernando Macedo , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: 2.4.0\n" +"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n" +"POT-Creation-Date: 2023-03-04 16:10-0300\n" +"PO-Revision-Date: 2024-06-07 17:41-0300\n" +"Last-Translator: Fernando Macedo \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + +#: statemachine/callbacks.py:165 +msgid "Failed to parse boolean expression '{}'" +msgstr "无法解析布尔表达式 '{}'" + +#: statemachine/callbacks.py:407 statemachine/callbacks.py:412 +msgid "Did not found name '{}' from model or statemachine" +msgstr "在模型或状态机中未找到名称 '{}'" + +#: statemachine/exceptions.py:24 +msgid "{!r} is not a valid state value." +msgstr "{!r} 不是有效的状态值。" + +#: statemachine/exceptions.py:38 +msgid "Can't {} when in {}." +msgstr "在 {} 时无法 {}。" + +#: statemachine/factory.py:74 +msgid "There are no states." +msgstr "没有状态。" + +#: statemachine/factory.py:77 +msgid "There are no events." +msgstr "没有事件。" + +#: statemachine/factory.py:89 +msgid "" +"There should be one and only one initial state. You currently have these:" +" {!r}" +msgstr "应有且仅有一个初始状态。当前您有这些:{!r}" + +#: statemachine/factory.py:102 +msgid "Cannot declare transitions from final state. Invalid state(s): {}" +msgstr "无法从终止状态声明转换。无效状态:{}" + +#: statemachine/factory.py:110 +msgid "" +"All non-final states should have at least one outgoing transition. These " +"states have no outgoing transition: {!r}" +msgstr "所有非终止状态都应至少有一个外部转换。这些状态没有外部转换:{!r}" + +#: statemachine/factory.py:124 +msgid "" +"All non-final states should have at least one path to a final state. " +"These states have no path to a final state: {!r}" +msgstr "所有非终止状态应至少有一个到终止状态的路径。这些状态没有到终止状态的路径:{!r}" + +#: statemachine/factory.py:148 +msgid "" +"There are unreachable states. The statemachine graph should have a single" +" component. Disconnected states: {}" +msgstr "存在不可到达的状态。状态机图应具有单个组件。断开的状态:{}" + +#: statemachine/factory.py:257 +msgid "An event in the '{}' has no id." +msgstr "'{}' 中的事件没有 ID。" + +#: statemachine/mixins.py:26 +msgid "{!r} is not a valid state machine name." +msgstr "{!r} 不是有效的状态机名称。" + +#: statemachine/state.py:155 +msgid "State overriding is not allowed. Trying to add '{}' to {}" +msgstr "不允许覆盖状态。尝试将 '{}' 添加到 {}" + +#: statemachine/statemachine.py:94 +msgid "There are no states or transitions." +msgstr "没有状态或转换。" + +#: statemachine/statemachine.py:285 +msgid "" +"There's no current state set. In async code, did you activate the initial" +" state? (e.g., `await sm.activate_initial_state()`)" +msgstr "没有设置当前状态。在异步代码中,您是否激活了初始状态?(例如,`await sm.activate_initial_state()`)" + +#: statemachine/engines/async_.py:22 +msgid "Only RTC is supported on async engine" +msgstr "异步引擎仅支持 RTC"