diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 616fbf02..35912b19 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,7 +31,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Assemble site run: | mkdir -p _site/schemas/statechart @@ -44,8 +44,8 @@ jobs:

See the statechart JSON Schema or the documentation.

HTML - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: _site - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ac417f8d..6f6dfe0c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -21,16 +21,16 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: git fetch origin develop - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v10.1.0 with: enable-cache: true cache-suffix: "python${{ matrix.python-version }}" @@ -55,7 +55,7 @@ jobs: # upload coverage #---------------------------------------------- - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 if: matrix.python-version == 3.14 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baf3fd1e..c571da18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: git fetch origin develop - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' @@ -24,7 +24,7 @@ jobs: uses: ts-graphviz/setup-graphviz@v2 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v10.1.0 with: enable-cache: true @@ -40,7 +40,7 @@ jobs: uv build - name: Upload dists - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-dists path: dist/ @@ -59,7 +59,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: release-dists path: dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b2119bf..9f96d446 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: exclude: docs/auto_examples - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.17 + rev: v0.16.7 hooks: # Run the linter. - id: ruff diff --git a/docs/diagram.md b/docs/diagram.md index d48443d3..a6512a75 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -214,6 +214,7 @@ Sphinx directive): ```python from statemachine.contrib.diagram import formatter + @formatter.register_format("plantuml", "puml") def _render_plantuml(machine_or_class): # your PlantUML renderer here @@ -525,6 +526,7 @@ class CustomDiagram(DotGraphMachine): graph_rankdir = "TB" state_active_fillcolor = "lightyellow" + sm = OrderControl() sm.receive_payment(10) diff --git a/docs/guards.md b/docs/guards.md index 8ce5a0ec..35455604 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -254,7 +254,7 @@ listeners. They can point to properties, attributes, or methods: **Parentheses** control evaluation order: ```python -cond="(is_admin or is_moderator) and not is_banned" +cond = "(is_admin or is_moderator) and not is_banned" ``` #### Expression examples diff --git a/docs/how-to/coming_from_state_pattern.md b/docs/how-to/coming_from_state_pattern.md index eccc6688..e04296a7 100644 --- a/docs/how-to/coming_from_state_pattern.md +++ b/docs/how-to/coming_from_state_pattern.md @@ -28,16 +28,13 @@ class OrderState(ABC): """Abstract base for all order states.""" @abstractmethod - def confirm(self, order): - ... + def confirm(self, order): ... @abstractmethod - def ship(self, order): - ... + def ship(self, order): ... @abstractmethod - def deliver(self, order): - ... + def deliver(self, order): ... class DraftState(OrderState): diff --git a/docs/how-to/coming_from_transitions.md b/docs/how-to/coming_from_transitions.md index 30e8f1e2..791c4765 100644 --- a/docs/how-to/coming_from_transitions.md +++ b/docs/how-to/coming_from_transitions.md @@ -233,7 +233,7 @@ that reads SCXML, JSON and YAML *documents* straight into a `StateChart`: ```python from statemachine.io import load -Machine = load("traffic_light.scxml") # or .json / .yaml; format detected from the extension +Machine = load("traffic_light.scxml") # or .json / .yaml; format detected from the extension ``` Expressions in the document (guards, datamodel) are evaluated by a restricted allowlist — @@ -306,8 +306,8 @@ True In *transitions*, events are called as methods on the model: ```python -machine.produce() # triggers the "produce" event -machine.deliver() # triggers the "deliver" event +machine.produce() # triggers the "produce" event +machine.deliver() # triggers the "deliver" event ``` python-statemachine supports both styles: @@ -342,13 +342,15 @@ Callbacks are specified as strings (method names) or callables: ```python machine = Machine( states=states, - transitions=[{ - "trigger": "produce", - "source": "draft", - "dest": "producing", - "before": "validate_job", - "after": "notify_team", - }], + transitions=[ + { + "trigger": "produce", + "source": "draft", + "dest": "producing", + "before": "validate_job", + "after": "notify_team", + } + ], initial="draft", ) ``` @@ -454,7 +456,9 @@ In *transitions*: ```python machine.add_transition( - "produce", "draft", "producing", + "produce", + "draft", + "producing", conditions=["is_valid", "has_resources"], unless=["is_locked"], ) @@ -588,10 +592,12 @@ See {ref}`invoke` for full documentation. ```python from transitions.extensions import AsyncMachine + class AsyncModel: async def on_enter_producing(self): await some_async_operation() + machine = AsyncMachine(model=AsyncModel(), states=states, initial="draft") await machine.produce() ``` @@ -752,6 +758,7 @@ No class swapping, no feature matrices to consult — just `StateChart`. class MyModel: pass + model = MyModel() machine = Machine(model=model, states=states, transitions=transitions, initial="draft") model.produce() # events are added to the model diff --git a/docs/integrations.md b/docs/integrations.md index fd362cee..ed1ffa38 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -120,10 +120,11 @@ from statemachine import State class CampaignMachine(StateChart): "A workflow machine" - draft = State('Draft', initial=True, value=1) - producing = State('Being produced', value=2) - closed = State('Closed', value=3) - cancelled = State('Cancelled', value=4) + + draft = State("Draft", initial=True, value=1) + producing = State("Being produced", value=2) + closed = State("Closed", value=3) + cancelled = State("Cancelled", value=4) add_job = draft.to.itself() | producing.to.itself() produce = draft.to(producing) @@ -142,9 +143,9 @@ from statemachine.mixins import MachineMixin class Campaign(models.Model, MachineMixin): - state_machine_name = 'campaign.statemachines.CampaignMachine' - state_machine_attr = 'sm' - state_field_name = 'step' + state_machine_name = "campaign.statemachines.CampaignMachine" + state_machine_attr = "sm" + state_field_name = "step" name = models.CharField(max_length=30) step = models.IntegerField() diff --git a/docs/io/security.md b/docs/io/security.md index a121fceb..8984b5bd 100644 --- a/docs/io/security.md +++ b/docs/io/security.md @@ -201,17 +201,17 @@ every format (SCXML, JSON and YAML). A set of follow-up advisories hardened the restricted mode further and prompted the confidentiality/integrity vs availability framing above: -- [GHSA-fj3w-533r-fvf6](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-fj3w-533r-fvf6) - — `` and `` read local files during loading, regardless of +- [GHSA-fj3w-533r-fvf6](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-fj3w-533r-fvf6): + `` and `` read local files during loading, regardless of `trusted`. Loading now rejects external `src` references unless `trusted=True`, and refuses ``/DTD to block XML entity-expansion bombs. - [GHSA-v3qq-3xvg-m77g](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-v3qq-3xvg-m77g) - / [GHSA-4857-ggqc-p3jc](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-4857-ggqc-p3jc) - — a document could write to a dunder/private/protected attribute (notably traversing + / [GHSA-4857-ggqc-p3jc](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-4857-ggqc-p3jc): + a document could write to a dunder/private/protected attribute (notably traversing `__class__`) and corrupt the shared model class process-wide. Write targets are now confined to public model attributes on every path segment. -- [GHSA-r8gj-366q-cgvj](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-r8gj-366q-cgvj) - — `**`/`*` in the restricted evaluator had no magnitude bound, so a tiny expression could +- [GHSA-r8gj-366q-cgvj](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-r8gj-366q-cgvj): + `**`/`*` in the restricted evaluator had no magnitude bound, so a tiny expression could exhaust CPU or memory. They are now magnitude-capped. These were released together in 3.2.1. diff --git a/docs/releases/1.0.1.md b/docs/releases/1.0.1.md index d32b6fcb..e497bf64 100644 --- a/docs/releases/1.0.1.md +++ b/docs/releases/1.0.1.md @@ -27,14 +27,15 @@ Transitions now support `cond` and `unless` parameters, to restrict the execution. ```python - class ApprovalMachine(StateMachine): - "A workflow machine" - requested = State("Requested", initial=True) - accepted = State("Accepted") - rejected = State("Rejected") - completed = State("Completed") +class ApprovalMachine(StateMachine): + "A workflow machine" + + requested = State("Requested", initial=True) + accepted = State("Accepted") + rejected = State("Rejected") + completed = State("Completed") - validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) + validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) ``` ```{seealso} @@ -114,9 +115,10 @@ So, the previous code (not valid anymore): ```py class ApprovalMachine(StateMachine): "A workflow machine" - requested = State('Requested', initial=True) - accepted = State('Accepted') - rejected = State('Rejected') + + requested = State("Requested", initial=True) + accepted = State("Accepted") + rejected = State("Rejected") validate = requested.to(accepted, rejected) @@ -133,6 +135,7 @@ Should be rewritten to use {ref}`guards`, like this: ``` py class ApprovalMachine(StateMachine): "A workflow machine" + requested = State("Requested", initial=True) accepted = State("Accepted") rejected = State("Rejected") @@ -168,14 +171,16 @@ This was the previous behavior, you only got an error when trying to instantiate ```py class CampaignMachine(StateMachine): "A workflow machine" - draft = State('Draft', initial=True) - producing = State('Being produced') - closed = State('Closed', initial=True) # Should raise an Exception when instantiated + + draft = State("Draft", initial=True) + producing = State("Being produced") + closed = State("Closed", initial=True) # Should raise an Exception when instantiated add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) + with pytest.raises(exceptions.InvalidDefinition): CampaignMachine() ``` @@ -187,6 +192,7 @@ with pytest.raises(exceptions.InvalidDefinition): class CampaignMachine(StateMachine): "A workflow machine" + draft = State("Draft", initial=True) producing = State("Being produced") closed = State( diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md index fbc433f1..0d2c48e3 100644 --- a/docs/releases/2.0.0.md +++ b/docs/releases/2.0.0.md @@ -291,7 +291,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() sm.run("cycle") - ``` Should become: @@ -313,7 +312,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert [t.name for t in sm.allowed_transitions] == ["cycle"] - ``` Should become: @@ -333,7 +331,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.is_green - ``` Should become: @@ -355,7 +352,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.current_state.identification == "green" - ``` Should become: diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md index 9ac3e83a..84f22105 100644 --- a/docs/releases/2.2.0.md +++ b/docs/releases/2.2.0.md @@ -42,16 +42,19 @@ This will currently issue a warning, but can be turned into an exception by sett ```python from statemachine import StateMachine, State + class TrafficLightMachine(StateMachine, strict_states=True): "A workflow machine" - red = State('Red', initial=True, value=1) - green = State('Green', value=2) - orange = State('Orange', value=3) - hazard = State('Hazard', value=4) + + red = State("Red", initial=True, value=1) + green = State("Green", value=2) + orange = State("Orange", value=3) + hazard = State("Hazard", value=4) cycle = red.to(green) | green.to(orange) | orange.to(red) fault = red.to(hazard) | green.to(hazard) | orange.to(hazard) + # InvalidDefinition: All non-final states should have at least one outgoing transition. # These states have no outgoing transition: ['hazard'] ``` diff --git a/docs/releases/2.3.0.md b/docs/releases/2.3.0.md index 83bb0901..08ff10c9 100644 --- a/docs/releases/2.3.0.md +++ b/docs/releases/2.3.0.md @@ -28,8 +28,8 @@ async code with a state machine. ```python class AsyncStateMachine(StateMachine): - initial = State('Initial', initial=True) - final = State('Final', final=True) + initial = State("Initial", initial=True) + final = State("Final", final=True) advance = initial.to(final) @@ -42,6 +42,7 @@ async def run_sm(): res = await sm.advance() return (42, sm.current_state.name) + asyncio.run(run_sm()) # (42, 'Final') ``` diff --git a/docs/releases/2.4.0.md b/docs/releases/2.4.0.md index 8054af36..2979cc1d 100644 --- a/docs/releases/2.4.0.md +++ b/docs/releases/2.4.0.md @@ -19,6 +19,7 @@ Example (with a spoiler of the next highlight): ```python from statemachine import StateMachine, State, Event + class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) @@ -31,6 +32,7 @@ class AnyConditionSM(StateMachine): used_money: bool = False used_credit: bool = False + sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md index 8cfe5840..babede62 100644 --- a/docs/releases/2.5.0.md +++ b/docs/releases/2.5.0.md @@ -66,6 +66,7 @@ listing the current state allowed events and executing the simulated user choice ```python import random + random.seed("15") sm = AccountStateMachine() @@ -76,7 +77,7 @@ while not sm.current_state.final: for idx, event in enumerate(allowed_events): print(f"{idx} - {event.name}") - user_input = random.randint(0, len(allowed_events)-1) + user_input = random.randint(0, len(allowed_events) - 1) print(f"User input: {user_input}") event = allowed_events[user_input] @@ -104,6 +105,7 @@ Example: ```python from statemachine import StateMachine, State, Event + class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) @@ -115,6 +117,7 @@ class AnyConditionSM(StateMachine): order_value: float = 0 + sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. diff --git a/docs/releases/3.1.0.md b/docs/releases/3.1.0.md index 7e518612..8557268c 100644 --- a/docs/releases/3.1.0.md +++ b/docs/releases/3.1.0.md @@ -41,9 +41,9 @@ from statemachine.contrib.diagram import formatter formatter.render(sm, "mermaid") formatter.supported_formats() + @formatter.register_format("custom") -def _render_custom(machine_or_class): - ... +def _render_custom(machine_or_class): ... ``` See {ref}`formatter-api` for details. @@ -77,6 +77,7 @@ class TrafficLight(StateChart): {statechart:md} """ + green = State(initial=True) yellow = State() red = State() @@ -163,6 +164,7 @@ async def fetch_data(): resp = await session.get("https://api.example.com/data") return await resp.json() + class Loader(StateChart): loading = State(initial=True, invoke=fetch_data) ready = State(final=True) diff --git a/docs/releases/3.2.0.md b/docs/releases/3.2.0.md index 85f72e33..9b3cd135 100644 --- a/docs/releases/3.2.0.md +++ b/docs/releases/3.2.0.md @@ -68,7 +68,7 @@ conformance suite), opt back into full Python with `trusted=True`: ```python from statemachine.io.scxml.processor import SCXMLProcessor -SCXMLProcessor() # safe default: restricted evaluator,