diff --git a/docs/guards.md b/docs/guards.md index 241eb698..57b996e3 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -65,9 +65,20 @@ The mini-language is based on Python's built-in language and the [`ast`](https:/ 1. `not` / `!` — Logical negation 2. `and` / `^` — Logical conjunction 3. `or` / `v` — Logical disjunction + 4. `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. +2. **Comparisson operators**: + - The following comparison operators are supported: + 1. `>` — Greather than. + 2. `>=` — Greather than or equal. + 3. `==` — Equal. + 4. `!=` — Not equal. + 5. `<` — Lower than. + 6. `<=` — Lower than or equal. + - See the [comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) from Python's. + 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. diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md new file mode 100644 index 00000000..fbdb8b01 --- /dev/null +++ b/docs/releases/2.5.0.md @@ -0,0 +1,188 @@ +# StateMachine 2.5.0 + +*December 3, 2024* + +## What's new in 2.5.0 + +This release improves {ref}`Condition expressions` and explicit definition of {ref}`Events` and introduces the helper `State.from_.any()`. + +### Python compatibility in 2.5.0 + +StateMachine 2.5.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. + +### Helper to declare transition from any state + +You can now declare that a state is accessible from any other state with a simple constructor. Using `State.from_.any()`, the state machine meta class automatically creates transitions from all non-final states to the target state. + +Furthermore, both `State.from_.itself()` and `State.to.itself()` have been refactored to support type hints and are now fully visible for code completion in your preferred editor. + +``` py +>>> from statemachine import Event + +>>> class AccountStateMachine(StateMachine): +... active = State("Active", initial=True) +... suspended = State("Suspended") +... overdrawn = State("Overdrawn") +... closed = State("Closed", final=True) +... +... suspend = Event(active.to(suspended)) +... activate = Event(suspended.to(active)) +... overdraft = Event(active.to(overdrawn)) +... resolve_overdraft = Event(overdrawn.to(active)) +... +... close_account = Event(closed.from_.any(cond="can_close_account")) +... +... can_close_account: bool = True +... +... def on_close_account(self): +... print("Account has been closed.") + +>>> sm = AccountStateMachine() +>>> sm.close_account() +Account has been closed. +>>> sm.closed.is_active +True + +``` + + +### Allowed events are now bounded to the state machine instance + +Since 2.0, the state machine can return a list of allowed events given the current state: + +``` +>>> sm = AccountStateMachine() +>>> [str(e) for e in sm.allowed_events] +['suspend', 'overdraft', 'close_account'] + +``` + +`Event` instances are now bound to the state machine instance, allowing you to pass the event by reference and call it like a method, which triggers the event in the state machine. + +You can think of the event as an implementation of the **command** design pattern. + +On this example, we iterate until the state machine reaches a final state, +listing the current state allowed events and executing the simulated user choice: + +``` +>>> import random +>>> random.seed("15") + +>>> sm = AccountStateMachine() + +>>> while not sm.current_state.final: +... allowed_events = sm.allowed_events +... print("Choose an action: ") +... for idx, event in enumerate(allowed_events): +... print(f"{idx} - {event.name}") +... +... user_input = random.randint(0, len(allowed_events)-1) +... print(f"User input: {user_input}") +... +... event = allowed_events[user_input] +... print(f"Running the option {user_input} - {event.name}") +... event() +Choose an action: +0 - Suspend +1 - Overdraft +2 - Close account +User input: 0 +Running the option 0 - Suspend +Choose an action: +0 - Activate +1 - Close account +User input: 0 +Running the option 0 - Activate +Choose an action: +0 - Suspend +1 - Overdraft +2 - Close account +User input: 2 +Running the option 2 - Close account +Account has been closed. + +>>> print(f"SM is in {sm.current_state.name} state.") +SM is in Closed state. + +``` + +### Conditions expressions in 2.5.0 + +This release adds support for comparison operators into {ref}`Condition expressions`. + +The following comparison operators are supported: + 1. `>` — Greather than. + 2. `>=` — Greather than or equal. + 3. `==` — Equal. + 4. `!=` — Not equal. + 5. `<` — Lower than. + 6. `<=` — Lower than or equal. + +Example: + +```py +>>> from statemachine import StateMachine, State, Event + +>>> class AnyConditionSM(StateMachine): +... start = State(initial=True) +... end = State(final=True) +... +... submit = Event( +... start.to(end, cond="order_value > 100"), +... name="finish order", +... ) +... +... order_value: float = 0 + +>>> sm = AnyConditionSM() +>>> sm.submit() +Traceback (most recent call last): +TransitionNotAllowed: Can't finish order when in Start. + +>>> sm.order_value = 135.0 +>>> 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. +``` + +### Decorator callbacks with explicit event creation in 2.5.0 + +Now you can add callbacks using the decorator syntax using {ref}`Events`. Note that this syntax is also available without the explicit `Event`. + +```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") +... +... @start.on +... def call_service(self): +... return "calling..." +... + +>>> sm = StartMachine() +>>> sm.start() +'calling...' + + +``` + + +## Bugfixes in 2.5.0 + +- Fixes [#500](https://github.com/fgmacedo/python-statemachine/issues/500) issue adding support for Pickle. + + +## Misc in 2.5.0 + +- We're now using `uv` [#491](https://github.com/fgmacedo/python-statemachine/issues/491). +- Simplification of the engines code [#498](https://github.com/fgmacedo/python-statemachine/pull/498). +- The dispatcher and callback modules where refactored with improved separation of concerns [#490](https://github.com/fgmacedo/python-statemachine/pull/490). diff --git a/docs/releases/index.md b/docs/releases/index.md index d2c94262..9e06124b 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.5.0 2.4.0 2.3.6 2.3.5 diff --git a/pyproject.toml b/pyproject.toml index 235faed4..298567eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "pytest-mock >=3.10.0", "pytest-benchmark >=4.0.0", "pytest-asyncio", + "pydot", "django >=5.0.8; python_version >='3.10'", "pytest-django >=4.8.0; python_version >'3.8'", "Sphinx; python_version >'3.8'", @@ -51,6 +52,7 @@ dev = [ "sphinx-autobuild; python_version >'3.8'", "furo >=2024.5.6; python_version >'3.8'", "sphinx-copybutton >=0.5.2; python_version >'3.8'", + "pdbr>=0.8.9; python_version >'3.8'", ] [build-system] @@ -61,7 +63,21 @@ build-backend = "hatchling.build" packages = ["statemachine/"] [tool.pytest.ini_options] -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" +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", + "--pdbcls=pdbr:RichPdb", +] 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"')"""] diff --git a/statemachine/spec_parser.py b/statemachine/spec_parser.py index 321da80b..7596c083 100644 --- a/statemachine/spec_parser.py +++ b/statemachine/spec_parser.py @@ -1,10 +1,27 @@ import ast +import operator import re +from functools import reduce from typing import Callable replacements = {"!": "not ", "^": " and ", "v": " or "} -pattern = re.compile(r"\!|\^|\bv\b") +pattern = re.compile(r"\!(?!=)|\^|\bv\b") + +comparison_repr = { + operator.eq: "==", + operator.ne: "!=", + operator.gt: ">", + operator.ge: ">=", + operator.lt: "<", + operator.le: "<=", +} + + +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 replace_operators(expr: str) -> str: @@ -25,12 +42,6 @@ def decorated(*args, **kwargs) -> bool: 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] @@ -49,7 +60,30 @@ def decorated(*args, **kwargs) -> bool: return decorated -def build_expression(node, variable_hook, operator_mapping): +def build_constant(constant) -> Callable: + def decorated(*args, **kwargs): + return constant + + decorated.__name__ = str(constant) + decorated.unique_key = str(constant) # type: ignore[attr-defined] + return decorated + + +def build_custom_operator(operator) -> Callable: + operator_repr = comparison_repr[operator] + + def custom_comparator(left: Callable, right: Callable) -> Callable: + def decorated(*args, **kwargs) -> bool: + return bool(operator(left(*args, **kwargs), right(*args, **kwargs))) + + decorated.__name__ = f"({left.__name__} {operator_repr} {right.__name__})" + decorated.unique_key = _unique_key(left, right, operator_repr) # type: ignore[attr-defined] + return decorated + + return custom_comparator + + +def build_expression(node, variable_hook, operator_mapping): # noqa: C901 if isinstance(node, ast.BoolOp): # Handle `and` / `or` operations operator_fn = operator_mapping[type(node.op)] @@ -58,6 +92,18 @@ def build_expression(node, variable_hook, operator_mapping): right_expr = build_expression(right, variable_hook, operator_mapping) left_expr = operator_fn(left_expr, right_expr) return left_expr + elif isinstance(node, ast.Compare): + # Handle `==` / `!=` / `>` / `<` / `>=` / `<=` operations + expressions = [] + left_expr = build_expression(node.left, variable_hook, operator_mapping) + for right_op, right in zip(node.ops, node.comparators): # noqa: B905 # strict=True requires 3.10+ + right_expr = build_expression(right, variable_hook, operator_mapping) + operator_fn = operator_mapping[type(right_op)] + expression = operator_fn(left_expr, right_expr) + left_expr = right_expr + expressions.append(expression) + + return reduce(custom_and, expressions) elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): # Handle `not` operation operand_expr = build_expression(node.operand, variable_hook, operator_mapping) @@ -65,6 +111,17 @@ def build_expression(node, variable_hook, operator_mapping): elif isinstance(node, ast.Name): # Handle variables by calling the variable_hook return variable_hook(node.id) + elif isinstance(node, ast.Constant): + # Handle constants by returning the value + return build_constant(node.value) + elif hasattr(ast, "NameConstant") and isinstance( + node, ast.NameConstant + ): # pragma: no cover | python3.7 + return build_constant(node.value) + elif hasattr(ast, "Str") and isinstance(node, ast.Str): # pragma: no cover | python3.7 + return build_constant(node.s) + elif hasattr(ast, "Num") and isinstance(node, ast.Num): # pragma: no cover | python3.7 + return build_constant(node.n) else: raise ValueError(f"Unsupported expression structure: {node.__class__.__name__}") @@ -80,4 +137,14 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping): return build_expression(tree.body, variable_hook, operator_mapping) -operator_mapping = {ast.Or: custom_or, ast.And: custom_and, ast.Not: custom_not} +operator_mapping = { + ast.Or: custom_or, + ast.And: custom_and, + ast.Not: custom_not, + ast.GtE: build_custom_operator(operator.ge), + ast.Gt: build_custom_operator(operator.gt), + ast.LtE: build_custom_operator(operator.le), + ast.Lt: build_custom_operator(operator.lt), + ast.Eq: build_custom_operator(operator.eq), + ast.NotEq: build_custom_operator(operator.ne), +} diff --git a/statemachine/state.py b/statemachine/state.py index 65ac7221..cc026762 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -36,6 +36,7 @@ def __call__(self, *states: "State", **kwargs): class _FromState(_TransitionBuilder): def any(self, **kwargs): + """Create transitions from all non-finalstates (reversed).""" return self.__call__(AnyState(), **kwargs) def __call__(self, *states: "State", **kwargs): diff --git a/tests/examples/lor_machine.py b/tests/examples/lor_machine.py index d262fa5f..b007fcb2 100644 --- a/tests/examples/lor_machine.py +++ b/tests/examples/lor_machine.py @@ -22,7 +22,7 @@ class LordOfTheRingsQuestStateMachine(StateMachine): 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") + start_journey = shire.to(bree, cond="frodo_has_ring and !sauron_alive and frodo_stamina > 90") 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") @@ -30,6 +30,7 @@ class LordOfTheRingsQuestStateMachine(StateMachine): destroy_ring = mordor.to(mount_doom, cond="frodo_has_ring and frodo_resists_ring") # Conditions (attributes representing the state of conditions) + frodo_stamina: int = 100 frodo_has_ring: bool = True sauron_alive: bool = True # Initially, Sauron is alive gandalf_present: bool = False # Gandalf is not present at the start diff --git a/tests/test_spec_parser.py b/tests/test_spec_parser.py index 95dba090..048e5e0c 100644 --- a/tests/test_spec_parser.py +++ b/tests/test_spec_parser.py @@ -1,8 +1,13 @@ +import logging + import pytest from statemachine.spec_parser import operator_mapping from statemachine.spec_parser import parse_boolean_expr +logger = logging.getLogger(__name__) +DEBUG = logging.DEBUG + def variable_hook(var_name): values = { @@ -11,9 +16,20 @@ def variable_hook(var_name): "gandalf_present": True, "sam_is_loyal": True, "orc_army_ready": False, + "frodo_age": 50, + "height": 1.75, + "name": "Frodo", + "aragorn_age": 87, + "legolas_age": 2931, + "gimli_age": 139, + "ring_power": 100, + "sword_power": 80, + "bow_power": 75, + "axe_power": 85, } def decorated(*args, **kwargs): + logger.debug(f"variable_hook({var_name})") return values.get(var_name, False) decorated.__name__ = var_name @@ -21,39 +37,118 @@ def decorated(*args, **kwargs): @pytest.mark.parametrize( - ("expression", "expected"), + ("expression", "expected", "hooks_called"), [ - ("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), + ("frodo_has_ring", True, ["frodo_has_ring"]), + ("frodo_has_ring or sauron_alive", True, ["frodo_has_ring"]), + ("frodo_has_ring and gandalf_present", True, ["frodo_has_ring", "gandalf_present"]), + ("sauron_alive", False, ["sauron_alive"]), + ("not sauron_alive", True, ["sauron_alive"]), + ( + "frodo_has_ring and (gandalf_present or sauron_alive)", + True, + ["frodo_has_ring", "gandalf_present"], + ), + ("not sauron_alive and orc_army_ready", False, ["sauron_alive", "orc_army_ready"]), + ("not (not sauron_alive and orc_army_ready)", True, ["sauron_alive", "orc_army_ready"]), + ( + "(frodo_has_ring and sam_is_loyal) or (not sauron_alive and orc_army_ready)", + True, + ["frodo_has_ring", "sam_is_loyal"], + ), + ( + "(frodo_has_ring ^ sam_is_loyal) v (!sauron_alive ^ orc_army_ready)", + True, + ["frodo_has_ring", "sam_is_loyal"], + ), + ("not (not frodo_has_ring)", True, ["frodo_has_ring"]), + ("!(!frodo_has_ring)", True, ["frodo_has_ring"]), + ("frodo_has_ring and orc_army_ready", False, ["frodo_has_ring", "orc_army_ready"]), + ("frodo_has_ring ^ orc_army_ready", False, ["frodo_has_ring", "orc_army_ready"]), + ("frodo_has_ring and not orc_army_ready", True, ["frodo_has_ring", "orc_army_ready"]), + ("frodo_has_ring ^ !orc_army_ready", True, ["frodo_has_ring", "orc_army_ready"]), + ( + "frodo_has_ring and (sam_is_loyal or (gandalf_present and not sauron_alive))", + True, + ["frodo_has_ring", "sam_is_loyal"], + ), + ( + "frodo_has_ring ^ (sam_is_loyal v (gandalf_present ^ !sauron_alive))", + True, + ["frodo_has_ring", "sam_is_loyal"], + ), + ("sauron_alive or orc_army_ready", False, ["sauron_alive", "orc_army_ready"]), + ("sauron_alive v orc_army_ready", False, ["sauron_alive", "orc_army_ready"]), + ( + "(frodo_has_ring and gandalf_present) or orc_army_ready", + True, + ["frodo_has_ring", "gandalf_present"], + ), + ( + "orc_army_ready or (frodo_has_ring and gandalf_present)", + True, + ["orc_army_ready", "frodo_has_ring", "gandalf_present"], + ), + ("orc_army_ready and (frodo_has_ring and gandalf_present)", False, ["orc_army_ready"]), + ( + "!orc_army_ready and (frodo_has_ring and gandalf_present)", + True, + ["orc_army_ready", "frodo_has_ring", "gandalf_present"], + ), + ( + "!orc_army_ready and !(frodo_has_ring and gandalf_present)", + False, + ["orc_army_ready", "frodo_has_ring", "gandalf_present"], + ), + ("frodo_has_ring or True", True, ["frodo_has_ring"]), + ("sauron_alive or True", True, ["sauron_alive"]), + ("frodo_age >= 50", True, ["frodo_age"]), + ("50 <= frodo_age", True, ["frodo_age"]), + ("frodo_age <= 50", True, ["frodo_age"]), + ("frodo_age == 50", True, ["frodo_age"]), + ("frodo_age > 50", False, ["frodo_age"]), + ("frodo_age < 50", False, ["frodo_age"]), + ("frodo_age != 50", False, ["frodo_age"]), + ("frodo_age != 49", True, ["frodo_age"]), + ("49 < frodo_age < 51", True, ["frodo_age", "frodo_age"]), + ("49 < frodo_age > 50", False, ["frodo_age", "frodo_age"]), + ( + "aragorn_age < legolas_age < gimli_age", + False, + ["aragorn_age", "legolas_age", "legolas_age", "gimli_age"], + ), # 87 < 2931 and 2931 < 139 + ( + "gimli_age > aragorn_age < legolas_age", + True, + ["gimli_age", "aragorn_age", "aragorn_age", "legolas_age"], + ), # 139 > 87 and 87 < 2931 + ( + "sword_power < ring_power > bow_power", + True, + ["sword_power", "ring_power", "ring_power", "bow_power"], + ), # 80 < 100 and 100 > 75 + ( + "axe_power > sword_power == bow_power", + False, + ["axe_power", "sword_power", "sword_power", "bow_power"], + ), # 85 > 80 and 80 == 75 + ("name == 'Frodo'", True, ["name"]), + ("name != 'Sam'", True, ["name"]), + ("height == 1.75", True, ["height"]), + ("height > 1 and height < 2", True, ["height", "height"]), ], ) -def test_expressions(expression, expected): +def test_expressions(expression, expected, caplog, hooks_called): + caplog.set_level(logging.DEBUG, logger="tests") + parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping) assert parsed_expr() is expected, expression + if hooks_called: + assert caplog.record_tuples == [ + ("tests.test_spec_parser", DEBUG, f"variable_hook({hook})") for hook in hooks_called + ] + def test_negating_compound_false_expression(): expr = "not (not sauron_alive and orc_army_ready)" @@ -97,12 +192,6 @@ def test_missing_operator_expression(): 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"): diff --git a/uv.lock b/uv.lock index dc4773ab..e1961d93 100644 --- a/uv.lock +++ b/uv.lock @@ -606,6 +606,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488 }, ] +[[package]] +name = "pdbr" +version = "0.8.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, + { name = "rich", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/1d/40420fda7c53fd071d8f62dcdb550c9f82fee54c2fda6842337890d87334/pdbr-0.8.9.tar.gz", hash = "sha256:3e0e1fb78761402bcfc0713a9c73acc2f639406b1b8da7233c442b965eee009d", size = 15942 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/66/250f546d090c94ad5291995b847f927afec9ca782c1944ac59ed20d34d12/pdbr-0.8.9-py3-none-any.whl", hash = "sha256:5d18e431cc69281626627199fd6a63dfeef26ccd7f4c11a8e7b53b288efd9a93", size = 16038 }, +] + [[package]] name = "pillow" version = "11.0.0" @@ -785,6 +798,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c", size = 104100 }, ] +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 }, +] + [[package]] name = "pytest" version = "7.4.4" @@ -947,8 +969,10 @@ dev = [ { name = "furo", marker = "python_full_version >= '3.9'" }, { name = "mypy" }, { name = "myst-parser", marker = "python_full_version >= '3.9'" }, + { name = "pdbr", marker = "python_full_version >= '3.9'" }, { name = "pillow", marker = "python_full_version >= '3.9'" }, { name = "pre-commit" }, + { name = "pydot" }, { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-asyncio" }, @@ -974,8 +998,10 @@ dev = [ { name = "furo", marker = "python_full_version >= '3.9'", specifier = ">=2024.5.6" }, { name = "mypy" }, { name = "myst-parser", marker = "python_full_version >= '3.9'" }, + { name = "pdbr", marker = "python_full_version >= '3.9'", specifier = ">=0.8.9" }, { name = "pillow", marker = "python_full_version >= '3.9'" }, { name = "pre-commit" }, + { name = "pydot" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-benchmark", specifier = ">=4.0.0" }, @@ -1057,6 +1083,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version >= '3.9'" }, + { name = "pygments", marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + [[package]] name = "ruff" version = "0.7.3"