From e54e3457186b3de0e824b372b76ff747d9c671b2 Mon Sep 17 00:00:00 2001 From: jernejfrank Date: Mon, 17 Aug 2026 16:03:13 +0100 Subject: [PATCH] Small POC for doctest and recipes --- burr/core/action.py | 13 +++++---- docs/concepts/state.rst | 22 +++++++-------- docs/conf.py | 1 + tests/docs/recipes/state.py | 54 +++++++++++++++++++++++++++++++++++++ tests/docs/test_recipes.py | 46 +++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 tests/docs/recipes/state.py create mode 100644 tests/docs/test_recipes.py diff --git a/burr/core/action.py b/burr/core/action.py index a69db06c6..2c496af51 100644 --- a/burr/core/action.py +++ b/burr/core/action.py @@ -1008,11 +1008,14 @@ def __or__(self, other: "Condition") -> "Condition": """Combines two conditions with an OR operator. This will return a new condition that is the OR of the two conditions. - To check if either foo is bar or baz is qux: - - .. code-block:: python - - condition = Condition.when(foo="bar") | Condition.when(baz="qux") + To check if either ``foo`` is ``"bar"`` or ``baz`` is ``"qux"``: + + >>> from burr.core import Condition, State + >>> condition = Condition.when(foo="bar") | Condition.when(baz="qux") + >>> condition.resolver(State({"foo": "bar", "baz": "nope"})) + True + >>> condition.resolver(State({"foo": "nope", "baz": "nope"})) + False :param other: Other condition to OR with :return: A new condition that is the OR of the two conditions diff --git a/docs/concepts/state.rst b/docs/concepts/state.rst index 7442313dd..ba2880144 100644 --- a/docs/concepts/state.rst +++ b/docs/concepts/state.rst @@ -32,15 +32,13 @@ The :py:class:`State ` class provides the ability to mani meaning that you can only create new states from old ones, not modify them in place. -State manipulation is done through calling methods on the ``State`` class. The most common write are: +State manipulation is done through calling methods on the ``State`` class. Because state is immutable, +each write returns a new ``State`` object. The most common writes are: -.. code-block:: python - - state.update(foo=bar) # update the state with the key "foo" set to "bar" - state.append(foo=bar) # append "bar" to the list at "foo" - state.increment(foo=1) # increment the value at "foo" by 1 - state.wipe(keep=["foo", "bar"]) # remove all keys except "foo" and "bar" - state.wipe(delete=["foo", "bar"]) # remove "foo" and "bar" from the state +.. literalinclude:: ../../tests/docs/recipes/state.py + :language: python + :start-after: # docs:start:manipulate-state + :end-before: # docs:end:manipulate-state .. warning:: @@ -54,10 +52,10 @@ State manipulation is done through calling methods on the ``State`` class. The m The read operations extend from those in the `Mapping `_ interface, but there are a few extra: -.. code-block:: python - - state.subset(["foo", "bar"]) # return a new state with only the keys "foo" and "bar" - state.get_all() # return a dictionary with every key/value of the state +.. literalinclude:: ../../tests/docs/recipes/state.py + :language: python + :start-after: # docs:start:read-state + :end-before: # docs:end:read-state When an update action is run, the state is first subsetted to get just the keys that are being read from, then the action is run, and a new state is written to. This state is merged back into the original state diff --git a/docs/conf.py b/docs/conf.py index e33a35196..9126778ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,6 +46,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", + "sphinx.ext.doctest", "myst_nb", "sphinx_sitemap", "sphinx_toolbox.collapse", diff --git a/tests/docs/recipes/state.py b/tests/docs/recipes/state.py new file mode 100644 index 000000000..88bd05be1 --- /dev/null +++ b/tests/docs/recipes/state.py @@ -0,0 +1,54 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# docs:start:manipulate-state +from burr.core import State + +state = State({"count": 1, "messages": ["hello"], "status": "draft"}) + +updated = state.update(status="ready") # set the key "status" to "ready" +appended = updated.append(messages="goodbye") # append "goodbye" to the list at "messages" +incremented = appended.increment(count=1) # increment the value at "count" by 1 + +kept = incremented.wipe(keep=["count", "messages"]) # remove all keys except these two +deleted = incremented.wipe(delete=["status"]) # remove "status" from the state +# docs:end:manipulate-state + + +# docs:start:read-state +message_state = incremented.subset("messages", "status") # new state with only these keys +all_values = incremented.get_all() # dictionary with every key/value of the state +# docs:end:read-state + + +assert state.get_all() == { + "count": 1, + "messages": ["hello"], + "status": "draft", +} +assert incremented.get_all() == { + "count": 2, + "messages": ["hello", "goodbye"], + "status": "ready", +} +assert kept.get_all() == {"count": 2, "messages": ["hello", "goodbye"]} +assert deleted.get_all() == kept.get_all() +assert message_state.get_all() == { + "messages": ["hello", "goodbye"], + "status": "ready", +} +assert all_values == incremented.get_all() diff --git a/tests/docs/test_recipes.py b/tests/docs/test_recipes.py new file mode 100644 index 000000000..87cc7354a --- /dev/null +++ b/tests/docs/test_recipes.py @@ -0,0 +1,46 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +RECIPES_DIR = Path(__file__).parent / "recipes" +REPOSITORY_ROOT = Path(__file__).parents[2] + + +@pytest.mark.parametrize("recipe", sorted(RECIPES_DIR.glob("*.py")), ids=lambda path: path.stem) +def test_recipe_runs_in_isolation(recipe: Path, tmp_path: Path) -> None: + env = os.environ.copy() + python_path = env.get("PYTHONPATH") + env["PYTHONPATH"] = os.pathsep.join( + part for part in (str(REPOSITORY_ROOT), python_path) if part + ) + + result = subprocess.run( + [sys.executable, str(recipe)], + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr