diff --git a/README.md b/README.md new file mode 100644 index 0000000..26135fb --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ +# pybind + +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Tests](https://github.com/FancyNeuron/pybind/actions/workflows/python-package.yml/badge.svg)](https://github.com/FancyNeuron/pybind/actions/workflows/python-package.yml) + +> Reactive programming for Python with reactive variables and events. + +pybind is a reactive programming library that lets you create Variables that automatically update when their dependencies change, plus an event system for notifying observers. + +## Installation + +```bash +git clone https://github.com/FancyNeuron/pybind.git +cd pybind +pip install -e . +``` + +## Quick Start + +```python +from pybind.int_values import IntVariable +from pybind.str_values import StrVariable + +# Create reactive variables +name = StrVariable("Alice") +age = IntVariable(25) + +# Create computed values that automatically update +greeting = name + " is " + age.to_str() + " years old" + +print(greeting) # "Alice is 25 years old" + +# Update source values - computed values update automatically! +name.value = "Bob" +age.value = 30 + +print(greeting) # "Bob is 30 years old" +``` + +## Core Concepts + +### Values, Variables and Events + +The foundation of pybind consists of three key components: + +**Values** are read-only reactive data that can be observed for changes. **Variables** are mutable Values that can be changed and bound to other Values. **Events** provide a way to notify observers when something happens. + +```python +from pybind.values import Constant +from pybind.int_values import IntVariable +from pybind.event import Event + +# Variables can be changed +counter = IntVariable(0) +counter.value = 10 + +# Constants cannot be changed +pi = Constant(3.14159) + +# Events notify observers +button_clicked = Event() +button_clicked.observe(lambda: print("Clicked!")) +button_clicked() # Prints: "Clicked!" +``` + +### Reactive Bindings + +Variables can be **bound** to other Values, making them automatically update: + +```python +from pybind.int_values import IntVariable + +# Create computed values +base = IntVariable(10) +multiplier = IntVariable(3) +result = base * multiplier + +# Bind variables to computed values +my_variable = IntVariable(0) +my_variable.bind_to(result) + +print(my_variable) # 30 + +# Updates propagate automatically +base.value = 20 +print(my_variable) # 60 + +# Unbind to break connections +my_variable.unbind() +``` + +## Advanced Features + +### Weak vs Strong Binding + +Control memory management with binding strength: + +```python +from pybind.str_values import StrVariable + +source = StrVariable("hello") +target = StrVariable("") + +# Strong binding (default) - keeps source alive +target.bind_to(source, bind_weakly=False) + +# Weak binding - allows source to be garbage collected +target.bind_to(source, bind_weakly=True) +``` + +### Circular Dependency Detection + +pybind automatically prevents circular dependencies: + +```python +from pybind.int_values import IntVariable + +a = IntVariable(1) +b = IntVariable(2) + +a.bind_to(b) +# b.bind_to(a) # This would raise RecursionError +``` + +### Observing Changes + +React to value changes with observers: + +```python +from pybind.int_values import IntVariable + +def on_value_change(new_value): + print(f"Value changed to: {new_value}") + +my_var = IntVariable(42) +my_var.observe(on_value_change) + +my_var.value = 100 # Prints: "Value changed to: 100" +``` + +## Event System + +pybind includes an event system for notifying observers when things happen. + +### Basic Events + +```python +from pybind.event import Event + +# Create an event +button_clicked = Event() + +# Add observers +def handle_click(): + print("Button was clicked!") + +button_clicked.observe(handle_click) + +# Trigger the event +button_clicked() # Prints: "Button was clicked!" +``` + +### Value Events + +Events that pass data to observers: + +```python +from pybind.event import ValueEvent + +user_logged_in = ValueEvent[str]() + +def welcome_user(username: str): + print(f"Welcome, {username}!") + +user_logged_in.observe(welcome_user) +user_logged_in("Alice") # Prints: "Welcome, Alice!" +``` + +### Multi-Parameter Events + +Events with multiple parameters: + +```python +from pybind.event import BiEvent, TriEvent + +# Two parameters +position_changed = BiEvent[int, int]() +position_changed.observe(lambda x, y: print(f"Position: ({x}, {y})")) +position_changed(10, 20) # Prints: "Position: (10, 20)" + +# Three parameters +rgb_changed = TriEvent[int, int, int]() +rgb_changed.observe(lambda r, g, b: print(f"Color: rgb({r}, {g}, {b})")) +rgb_changed(255, 128, 0) # Prints: "Color: rgb(255, 128, 0)" +``` + +### Weak Observation + +Prevent memory leaks with weak observers: + +```python +from pybind.event import Event + +event = Event() + +def temporary_handler(): + print("Handling event") + +# Weak observation - handler can be garbage collected +event.weak_observe(temporary_handler) +``` + +## Example Application + +Here's a practical example showing how to create automatically positioned windows: + +```python +from pybind.int_values import IntVariable + +class Window: + def __init__(self, x: int, y: int, width: int, height: int): + self.x = IntVariable(x) + self.y = IntVariable(y) + self.width = IntVariable(width) + self.height = IntVariable(height) + + def __repr__(self): + return f"Window(x={self.x.value}, y={self.y.value}, width={self.width.value}, height={self.height.value})" + +# Create two windows +main_window = Window(100, 50, 800, 600) +sidebar_window = Window(0, 0, 200, 400) + +# Automatically position sidebar to the right of main window +margin = IntVariable(10) +sidebar_window.x.bind_to(main_window.x + main_window.width + margin) +sidebar_window.y.bind_to(main_window.y) + +print(main_window) # Window(x=100, y=50, width=800, height=600) +print(sidebar_window) # Window(x=910, y=50, width=200, height=400) + +# Moving the main window automatically repositions the sidebar +main_window.x.value = 200 +main_window.y.value = 100 + +print(main_window) # Window(x=200, y=100, width=800, height=600) +print(sidebar_window) # Window(x=1010, y=100, width=200, height=400) + +# Changing margin updates sidebar position +margin.value = 20 +print(sidebar_window) # Window(x=1020, y=100, width=200, height=400) +``` + +## API Reference + +### Core Classes + +- **`Value[T]`** - Type for all reactive values, useful for typing function parameters +- **`Variable[T]`** - Type for mutable values, useful for typing function parameters +- **`Constant[T]`** - Immutable value + +### Type-Specific Classes + +- **`IntValue`**, **`IntVariable`** - Integer values with arithmetic operations +- **`FloatValue`**, **`FloatVariable`** - Float values with arithmetic operations +- **`StrValue`**, **`StrVariable`** - String values with concatenation +- **`BoolValue`** - Boolean values with logical operations + +### Event Classes + +- **`Event`** - Basic event with no parameters +- **`ValueEvent[T]`** - Event that passes one value +- **`BiEvent[S, T]`** - Event that passes two values +- **`TriEvent[S, T, U]`** - Event that passes three values + +## Development + +### Running Tests + +```bash +pytest +``` + +### Type Checking + +```bash +mypy src +``` + +### Linting + +```bash +flake8 . +``` + +--- + +Author: Georg Plaz \ No newline at end of file diff --git a/src/pybind/bool_values.py b/src/pybind/bool_values.py index 18c086b..d6498c0 100644 --- a/src/pybind/bool_values.py +++ b/src/pybind/bool_values.py @@ -2,7 +2,7 @@ from abc import ABC -from pybind.values import Value, DerivedValue, _S, _T +from pybind.values import Value, DerivedValue, _S, _T, Constant class BoolValue(Value[bool], ABC): @@ -16,3 +16,11 @@ def __init__(self, value: Value[bool]): def transform(self, value: bool) -> bool: return not value + + +class BoolConstant(BoolValue, Constant[bool]): + pass + + +TRUE = BoolConstant(True) +FALSE = BoolConstant(False) diff --git a/src/pybind/float_values.py b/src/pybind/float_values.py index d8a3cb8..14b0844 100644 --- a/src/pybind/float_values.py +++ b/src/pybind/float_values.py @@ -9,8 +9,7 @@ from pybind.observables import Observer, ValueObserver from pybind.values import Value, CombinedMixedValues, SimpleVariable, CombinedTwoValues, _create_value_getter, \ - DerivedValue, DerivedValueBase - + DerivedValue, DerivedValueBase, Constant FloatLike = int | Value[int] | float | Value[float] @@ -60,6 +59,10 @@ def __neg__(self) -> FloatValue: return NegateFloatValue(self) +class FloatConstant(FloatValue, Constant[float]): + pass + + def _create_float_getter(value: float | Value[int] | Value[float]) -> Callable[[], float]: if isinstance(value, Value): return lambda: value.value diff --git a/src/pybind/int_values.py b/src/pybind/int_values.py index 64e0cb5..c5d3329 100644 --- a/src/pybind/int_values.py +++ b/src/pybind/int_values.py @@ -6,7 +6,7 @@ from pybind.float_values import FloatValue, MultiplyFloatValues, DivideValues, SubtractFloatValues, \ AddFloatValues, CompareNumbersValues -from pybind.values import Value, CombinedMixedValues, SimpleVariable, CombinedTwoValues, DerivedValue +from pybind.values import Value, CombinedMixedValues, SimpleVariable, CombinedTwoValues, DerivedValue, Constant from pybind.bool_values import BoolValue IntLike = int | Value[int] @@ -108,6 +108,10 @@ def __neg__(self) -> IntValue: return NegateIntValue(self) +class IntConstant(IntValue, Constant[int]): + pass + + class IntVariable(SimpleVariable[int], IntValue): pass diff --git a/src/pybind/str_values.py b/src/pybind/str_values.py index 69ff39a..e0ad0c8 100644 --- a/src/pybind/str_values.py +++ b/src/pybind/str_values.py @@ -3,15 +3,22 @@ from abc import ABC from typing import Any -from pybind.values import Value, DerivedValue, CombinedMixedValues, SimpleVariable +from pybind.values import Value, DerivedValue, CombinedMixedValues, SimpleVariable, Constant StringLike = str | Value[str] class StrValue(Value[str], ABC): - def __add__(self, other: Value[str]) -> StrValue: + def __add__(self, other: StringLike) -> StrValue: return ConcatenateStrValues(self, other) + def __radd__(self, other: StringLike) -> StrValue: + return ConcatenateStrValues(other, self) + + +class StrConstant(StrValue, Constant[str]): + pass + class StrVariable(SimpleVariable[str], StrValue): pass diff --git a/src/pybind/values.py b/src/pybind/values.py index 95f2e70..62cea13 100644 --- a/src/pybind/values.py +++ b/src/pybind/values.py @@ -58,6 +58,12 @@ def to_str(self) -> StrValue: from pybind.str_values import ToStrValue return ToStrValue(self) + def __str__(self) -> str: + return str(self.value) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.value!r})" + class Variable(Value[_S], Generic[_S], ABC): @property diff --git a/tests/str_values/test_str_values.py b/tests/str_values/test_str_values.py new file mode 100644 index 0000000..f81283d --- /dev/null +++ b/tests/str_values/test_str_values.py @@ -0,0 +1,6 @@ +from pybind.str_values import StrConstant + + +def test_str_constant_str(): + const = StrConstant("hello") + assert str(const) == "hello" diff --git a/tests/test_boo_values.py b/tests/test_boo_values.py new file mode 100644 index 0000000..4253b47 --- /dev/null +++ b/tests/test_boo_values.py @@ -0,0 +1,9 @@ +from pybind.bool_values import BoolConstant + + +def test_bool_constant_str(): + const = BoolConstant(True) + assert str(const) == "True" + + const_false = BoolConstant(False) + assert str(const_false) == "False" diff --git a/tests/test_concatenate_str_values.py b/tests/test_concatenate_str_values.py index 9abae21..b09a76a 100644 --- a/tests/test_concatenate_str_values.py +++ b/tests/test_concatenate_str_values.py @@ -12,3 +12,25 @@ def test_concatenate_str_values(): variable1.value = "bar" assert concatenated.value == "foobar" + + +def test_concatenate_str_value_literal_str_value(): + first_name = StrVariable("Ada") + last_name = StrVariable("Lovelace") + full_name = first_name + " " + last_name + + assert full_name.value == "Ada Lovelace" + + +def test_concatenate_str_value_literal(): + first_name = StrVariable("Ada") + full_name = first_name + " Lovelace" + + assert full_name.value == "Ada Lovelace" + + +def test_concatenate_literal_str_value(): + last_name = StrVariable("Lovelace") + full_name = "Ada " + last_name + + assert full_name.value == "Ada Lovelace" diff --git a/tests/test_float_values.py b/tests/test_float_values.py new file mode 100644 index 0000000..72d8473 --- /dev/null +++ b/tests/test_float_values.py @@ -0,0 +1,6 @@ +from pybind.float_values import FloatConstant + + +def test_float_constant_str(): + const = FloatConstant(3.14) + assert str(const) == "3.14" diff --git a/tests/test_int_values.py b/tests/test_int_values.py new file mode 100644 index 0000000..4894a86 --- /dev/null +++ b/tests/test_int_values.py @@ -0,0 +1,6 @@ +from pybind.int_values import IntConstant + + +def test_int_constant_str(): + const = IntConstant(42) + assert str(const) == "42" diff --git a/tests/test_simple_variable.py b/tests/test_simple_variable.py index 600d87f..32baaf9 100644 --- a/tests/test_simple_variable.py +++ b/tests/test_simple_variable.py @@ -118,7 +118,7 @@ def test_simple_variable_bind_already_bound_ok(): assert variable.value == "value2" -def test_simple_variable_unbind(): +def test_simple_variable_change_after_unbind(): variable = SimpleVariable("initial") constant = Constant("bound_value") @@ -129,6 +129,25 @@ def test_simple_variable_unbind(): assert variable.value == "after_unbind" +def test_simple_variable_change_without_unbind_raises(): + variable = SimpleVariable("initial") + constant = Constant("bound_value") + + variable.bind_to(constant) + with pytest.raises(ValueError): + variable.value = "after_unbind" + + +def test_simple_variable_change_root_after_unbind(): + dependent = SimpleVariable("dependent") + root = SimpleVariable("root") + + dependent.bind_to(root) + dependent.unbind() + root.value = "new_root_value" + assert dependent.value == "root" + + def test_simple_variable_unbind_not_bound_error(): variable = SimpleVariable("test") @@ -364,3 +383,28 @@ def test_daisy_chain_variables_weak_reference_stays(): root_var.value = "root2" assert values == ["root1", "root2"] + + +def test_simple_int_variable_str(): + var_int = SimpleVariable(42) + assert str(var_int) == "42" + + +def test_simple_str_variable_str(): + var_str = SimpleVariable("hello") + assert str(var_str) == "hello" + + +def test_simple_float_variable_str(): + var_float = SimpleVariable(3.14) + assert str(var_float) == "3.14" + + +def test_simple_bool_variable_str(): + var_bool = SimpleVariable(True) + assert str(var_bool) == "True" + + +def test_simple_none_variable_str(): + none_bool = SimpleVariable(None) + assert str(none_bool) == "None"