diff --git a/.coveragerc b/.coveragerc index 6ffc95e..f4a632f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -12,5 +12,3 @@ omit = examples/* docs/* */__init__.py - - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8f5951..475ee9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,14 +27,14 @@ repos: - id: isort args: ["--profile", "black"] - # MyPy - Type checking - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 - hooks: - - id: mypy - additional_dependencies: [] - args: [--ignore-missing-imports] - files: ^(fynx|tests)/ + # MyPy - Type checking, disabled for now + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.18.2 + # hooks: + # - id: mypy + # additional_dependencies: [] + # args: [--ignore-missing-imports] + # files: ^(fynx|tests)/ # General pre-commit hooks - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/README.md b/README.md index 9edcdb8..be617b7 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,12 @@ class CartStore(Store): price_per_item = observable(10.0) # Define transformation function -def calculate_total(count_and_price): - count, price = count_and_price +def calculate_total(count, price): return count * price # Reactive computation using .then() -total_price = (CartStore.item_count | CartStore.price_per_item).then(calculate_total) +total_price = (CartStore.item_count + CartStore.price_per_item).then(calculate_total) +# total_price = (CartStore.item_count + CartStore.price_per_item) >> calculate_total # Equivalent! def print_total(total): print(f"Cart Total: ${total:.2f}") @@ -101,28 +101,43 @@ The common thread: data flows through transformations, and multiple parts of you This breadth isn't accidental. The universal properties underlying FynX apply to any scenario involving time-varying values and compositional transformations—which describes a surprisingly large fraction of software. + +## The Five Reactive Operators + +FynX provides five composable operators that form a complete algebra for reactive programming. You can use either the symbolic operators (`>>`, `+`, `&`, `|`, `~`) or their natural language method equivalents (`.then()`, `.alongside()`, `.requiring()`, `.either()`, `.negate()`): + +| Operator | Method | Operation | Purpose | Example | +|----------|--------|-----------|---------|---------| +| `>>` | `.then()` | Transform | Apply functions to values | `price >> (lambda p: f"${p:.2f}")` | +| `+` | `.alongside()` | Combine | Merge observables into read-only tuples | `(first + last) >> join` | +| `&` | `.requiring()` | Filter | Gate reactivity based on conditions | `file & valid & ~processing` | +| `\|` | `.either()` | Logical OR | Combine boolean conditions | `is_error \| is_warning` | +| `~` | `.negate()` | Negate | Invert boolean conditions | `~is_loading` | + +Each operation creates a new observable. Chain them to build sophisticated reactive systems from simple parts. These operators correspond to precise mathematical structures—functors, products, pullbacks—that guarantee correct behavior under composition. + ## The Mathematical Guarantee -Here's what makes FynX different: the reactive behavior doesn't just work for the examples you see—it works by mathematical necessity for any reactive program you could construct. FynX is built on solid mathematical foundations from category theory. These aren't abstractions for their own sake—they're implementation principles that guarantee correctness and enable powerful optimizations. +You don't need to understand category theory at all to use FynX, though it is what makes FynX different: the reactive behavior doesn't just work for the examples you see—it works by mathematical necessity for any reactive program you could construct. FynX is built on solid mathematical foundations from category theory. These aren't abstractions for their own sake—they're implementation principles that guarantee correctness and enable powerful optimizations. -FynX satisfies specific universal properties from category theory (covered in the [**Mathematical Foundations**](https://off-by-some.github.io/fynx/generation/markdown/mathematical-foundations/)) These aren't abstractions for their own sake; they're implementation principles that guarantee correctness: +You don't need to understand category theory to use FynX, but it's what makes FynX reliable: the reactive behavior isn't just validated by examples—it's guaranteed by mathematical necessity. Every reactive program you construct will work correctly because FynX is built on universal properties from category theory (detailed in the [**Mathematical Foundations**](https://off-by-some.github.io/fynx/generation/markdown/mathematical-foundations/)). These aren't abstract concepts for their own sake; they're implementation principles that ensure correctness and enable powerful optimizations. -* **Functoriality**: Any function you lift with `>>` preserves composition exactly. Chain transformations freely—the order of operations is guaranteed. -* **Products**: Combining observables with `|` creates proper categorical products. No matter how you nest combinations, the structure remains coherent. -* **Pullbacks**: Filtering with `&` constructs mathematical pullbacks. Stack conditions in any order—the semantics stay consistent. +FynX satisfies specific universal properties from category theory, guaranteeing correctness: +* **Functoriality**: Transformations with `>>` preserve composition. Your chains work exactly as expected, regardless of how you compose them. +* **Products**: Combining observables with `+` creates proper categorical products. No matter how complex your combinations, the structure stays coherent. +* **Pullbacks**: Filtering with `&` constructs mathematical pullbacks. Stack conditions freely—the meaning never changes. -The functoriality property guarantees that lifted functions preserve composition: +The functoriality property guarantees that lifted functions preserve composition: $$ \mathcal{O}(\mathrm{id}) = \mathrm{id} \quad \mathcal{O}(g \circ f) = \mathcal{O}g \circ \mathcal{O}f $$ -You don't need to understand category theory to use FynX. The mathematics works beneath the surface, ensuring that complex reactive systems composed from simple parts behave predictably under all transformations. Write declarative code describing relationships, and the universal properties guarantee those relationships hold. - -These same categorical structures also enable FynX's automatic optimizer—composition laws prove that `obs >> f >> g >> h` can safely fuse into a single operation, product properties allow sharing common computations, and pullback semantics let filters combine without changing meaning. The theory doesn't just ensure correctness; it shows exactly which performance optimizations preserve your program's semantics. +In practice, this means complex reactive systems composed from simple parts behave predictably under all transformations. You describe what relationships should exist; FynX guarantees they hold. -Think of it as a particularly thorough test suite—one that covers not just the cases you thought to write, but every possible case that could theoretically exist (but yes, we've got the ["real deal" tests](./tests/test_readme.py) if you want to live dangerously). +These same categorical structures enable FynX's automatic optimizer. Composition laws prove `obs >> f >> g >> h` can safely fuse into a single operation. Product properties allow sharing common computations. Pullback semantics let filters combine without changing meaning. The theory doesn't just ensure correctness—it shows exactly which optimizations are safe. +Think of it like an impossibly thorough test suite: one covering not just the cases you wrote, but every case that could theoretically exist. (We also ship with [conventional tests](./tests/), naturally.) ## Performance @@ -135,6 +150,7 @@ poetry run python scripts/benchmark.py ``` Below is a sample of the output from the above command: + ``` FynX Benchmark Configuration: TIME_LIMIT_SECONDS: 1.0 @@ -195,24 +211,12 @@ class AppState(Store): AppState.username = "off-by-some" # Normal assignment, reactive behavior ``` -Stores provide structure for related state and enable features like store-level reactions and serialization. With observables established, you compose them using FynX's four fundamental operators. - -## The Four Reactive Operators - -FynX provides four composable operators that form a complete algebra for reactive programming: - -| Operator | Operation | Purpose | Example | -|----------|-----------|---------|---------| -| `>>` | Transform | Apply functions to values | `price >> (lambda p: f"${p:.2f}")` | -| `\|` | Combine | Merge observables into tuples | `(first \| last) >> join` | -| `&` | Filter | Gate based on conditions | `file & valid & ~processing` | -| `~` | Negate | Invert boolean conditions | `~is_loading` | +Stores provide structure for related state and enable features like store-level reactions and serialization. With observables established, you compose them using FynX's five fundamental operators. -Each operation creates a new observable. Chain them to build sophisticated reactive systems from simple parts. These operators correspond to precise mathematical structures—functors, products, pullbacks—that guarantee correct behavior under composition. -## Transforming Data with `>>` +## Transforming Data with `>>` or `.then()` -The `>>` operator transforms observables through functions. Chain multiple transformations to build [derived observables](https://off-by-some.github.io/fynx/generation/markdown/derived-observables/): +The `>>` operator (or `.then()` method) transforms observables through functions. Chain multiple transformations to build [derived observables](https://off-by-some.github.io/fynx/generation/markdown/derived-observables/): ```python # Define transformation functions @@ -240,9 +244,10 @@ result_operator = (counter Each transformation creates a new observable that recalculates when its source changes. This chaining works predictably because `>>` implements functorial mapping—structure preservation under transformation. -## Combining Observables with `|` +## Combining Observables with `+` or `.alongside()` -Use `|` to combine multiple observables into reactive tuples: +Use `+` (or `.alongside()`) to combine multiple observables into reactive tuples. +Merged observables are read-only computed observables that derive their value from their source observables: ```python class User(Store): @@ -250,28 +255,31 @@ class User(Store): last_name = observable("Doe") # Define transformation function -def join_names(first_and_last): - first, last = first_and_last +def join_names(first, last): return f"{first} {last}" # Combine and transform using .then() -full_name_method = (User.first_name | User.last_name).then(join_names) +full_name_method = (User.first_name + User.last_name).then(join_names) # Alternative using >> operator -full_name_operator = (User.first_name | User.last_name) >> join_names +full_name_operator = (User.first_name + User.last_name) >> join_names + +# Merged observables are read-only +merged = User.first_name + User.last_name +# merged.set(("Jane", "Smith")) # Raises ValueError: Computed observables are read-only ``` When any combined observable changes, downstream values recalculate automatically. This operator constructs categorical products, ensuring combination remains symmetric and associative regardless of nesting. -> **Note:** The `|` operator will transition to `@` in a future release to support logical OR operations. +## Filtering with `&`, `.requiring()`, `~`, `.negate()`, `|`, and `.either()` -## Filtering with `&` and `~` - -The `&` operator filters observables to emit only when [conditions](https://off-by-some.github.io/fynx/generation/markdown/conditionals/) are met. Use `~` to negate: +The `&` operator (or `.requiring()`) filters observables to emit only when [conditions](https://off-by-some.github.io/fynx/generation/markdown/conditionals/) are met. Use `~` (or `.negate()`) to invert, and `|` (or `.either()`) for logical OR conditions: ```python uploaded_file = observable(None) is_processing = observable(False) +is_error = observable(False) +is_warning = observable(True) # Define validation function def is_valid_file(f): @@ -281,52 +289,83 @@ def is_valid_file(f): is_valid_method = uploaded_file.then(is_valid_file) is_valid_operator = uploaded_file >> is_valid_file -# Filter using & operator -preview_ready_method = uploaded_file & is_valid_method & (~is_processing) +# Filter using & operator (or .requiring() method) +preview_ready_method = uploaded_file.requiring(is_valid_method).requiring(is_processing.negate()) preview_ready_operator = uploaded_file & is_valid_operator & (~is_processing) + +# Logical OR using | operator (or .either() method) +needs_attention = is_error | is_warning +# Alternative: needs_attention = is_error.either(is_warning) ``` -The `preview_ready` observable emits only when all conditions align—file exists, it's valid, and processing is inactive. This filtering emerges from pullback constructions that create a "smart gate" filtering to the fiber where all conditions are True. +The `preview_ready` observable emits only when all conditions align—file exists, it's valid, and processing is inactive. The `needs_attention` observable emits when any error or warning condition is true. This filtering emerges from pullback constructions that create a "smart gate" filtering to the fiber where all conditions are True. ## Reacting to Changes -React to observable changes using the [`@reactive`](https://off-by-some.github.io/fynx/generation/markdown/using-reactive/) decorator or subscriptions: +React to observable changes using the [`@reactive`](https://off-by-some.github.io/fynx/generation/markdown/using-reactive/) decorator or subscriptions. + +**The fundamental principle**: `@reactive` is for side effects only—UI updates, logging, network calls, and other operations that interact with the outside world. For deriving new values from existing data, use the `>>` operator instead. This separation keeps your reactive system predictable and maintainable. + +**Important note on timing**: Reactive functions don't fire immediately when created—they only fire when their dependencies *change*. This follows from FynX's pullback semantics in category theory. If you need initialization logic, handle it separately before setting up the reaction. ```python from fynx import reactive -# Define reaction functions -def handle_change(value): - print(f"Changed: {value}") +# GOOD: Side effects with @reactive +@reactive(user_count) +def update_dashboard(count): + render_ui(f"Users: {count}") # Side effect: UI update + +@reactive(data_stream) +def sync_to_server(data): + api.post('/sync', data) # Side effect: network I/O -def print_new_value(x): - print(f"New value: {x}") +@reactive(error_log) +def log_errors(error): + print(f"Error: {error}") # Side effect: logging -# Dedicated reaction functions -@reactive(observable) -def handle_change(value): - print(f"Changed: {value}") +# GOOD: Data transformations with >> operator +doubled = count >> (lambda x: x * 2) # Pure transformation +formatted = doubled >> (lambda x: f"${x:.2f}") # Pure transformation -# Inline reactions -observable.subscribe(print_new_value) +# Inline subscriptions for dynamic behavior +observable.subscribe(lambda x: print(f"New value: {x}")) -# Conditional reactions using conditional observables -condition1 = observable(True) -condition2 = observable(False) +# Conditional reactions using boolean operators +is_logged_in = observable(False) +has_data = observable(False) +is_loading = observable(True) -def check_conditions(): - return condition1.value and condition2.value +# React only when logged in AND has data AND NOT loading +@reactive(is_logged_in & has_data & ~is_loading) +def sync_when_ready(should_sync): + if should_sync: + perform_sync() # Side effect: network operation -# Create conditional observable -all_conditions_met = (condition1 | condition2).then(check_conditions) +# Multiple observables via derived state +first_name = observable("Alice") +last_name = observable("Smith") + +# Derive first, then react +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") + +@reactive(full_name) +def update_greeting(name): + display_message(f"Hello, {name}!") # Side effect: UI update +``` + +**Lifecycle management**: Use `.unsubscribe()` to stop reactive behavior when cleaning up components or changing modes. After unsubscribing, the function returns to normal, non-reactive behavior and can be called manually again. + +```python +@reactive(data_stream) +def process_data(data): + handle_data(data) -@reactive(all_conditions_met) -def on_conditions_met(conditions_met): - if conditions_met: - print("All conditions satisfied!") +# Later, during cleanup +process_data.unsubscribe() # Stops reacting to changes ``` -Choose the pattern that fits your context. These reactions fire automatically because the dependency graph tracks relationships through the categorical structure underlying observables. +**Remember**: Use `@reactive` for side effects at your application's boundaries—where your pure reactive data flow meets the outside world. Use `>>`, `+`, `&`, `|`, and `~` for all data transformations and computations. This "functional core, reactive shell" pattern is what makes reactive systems both powerful and maintainable. ## Additional Examples @@ -347,7 +386,7 @@ These examples demonstrate how FynX's composable primitives scale from simple to Deep mathematics should enable simpler code, not complicate it. FynX grounds itself in category theory precisely because those abstractions—functors, products, pullbacks—capture the essence of composition without the accidents of implementation. Users benefit from mathematical rigor whether they recognize the theory or not. -The interface reflects this. Observables feel like ordinary values—read them, write them, pass them around. Reactivity works behind the scenes, tracking dependencies through categorical structure without requiring explicit wiring. Method chaining flows naturally: `observable(42).subscribe(print)` reads as plain description, not ceremony. The `>>` operator transforms, `|` combines, `&` filters—each produces new observables ready for further composition. Complex reactive systems emerge from simple, reusable pieces. +The interface reflects this. Observables feel like ordinary values—read them, write them, pass them around. Reactivity works behind the scenes, tracking dependencies through categorical structure without requiring explicit wiring. Method chaining flows naturally: `observable(42).subscribe(print)` reads as plain description, not ceremony. The `>>` operator transforms, `+` combines, `&` filters, `|` creates OR conditions, `~` negates—each produces new observables ready for further composition. Complex reactive systems emerge from simple, reusable pieces. FynX offers multiple APIs because different contexts call for different styles. Use decorators when conciseness matters, direct calls when you need explicit control, context managers when reactions should be scoped. The library adapts to your preferred way of working. diff --git a/docs/README.md b/docs/README.md index baf47af..bd42bbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,12 +9,14 @@ docs/ ├── README.md # This file ├── index.md # Main landing page (MkDocs compatible) ├── generation/ # Documentation generation files -│ ├── mkdocs.yml # MkDocs configuration -│ ├── markdown/ # Generated markdown documentation -│ │ └── api.md # API reference (processed by mkdocstrings) +│ ├── markdown/ # Documentation source files +│ │ ├── mkdocs.yml # MkDocs configuration +│ │ ├── tutorial/ # Tutorial content +│ │ ├── reference/ # API reference documentation +│ │ └── mathematical/ # Mathematical foundations │ └── scripts/ # Documentation generation scripts │ ├── generate_html.py # HTML documentation generator -│ └── preview_docs.sh # Preview server launcher +│ └── preview_html_docs.sh # Preview server launcher ├── images/ # Universal images and assets │ ├── banner.svg # Main logo/banner for documentation │ ├── fynx_icon.svg # Icon for favicons and logos @@ -34,14 +36,14 @@ python docs/generation/scripts/generate_html.py ### Previewing Documentation ```bash -bash docs/generation/scripts/preview_docs.sh +bash docs/generation/scripts/preview_html_docs.sh ``` This will: 1. Build HTML documentation using MkDocs with mkdocstrings -2. Output to the `site/` directory +2. Start a local development server at http://localhost:8000 ### Adding New Pages -1. Create markdown files directly in the `docs/` directory -2. Add entries to the `nav` section in `docs/build/mkdocs.yml` +1. Create markdown files in the appropriate subdirectory (`tutorial/`, `reference/`, or `mathematical/`) +2. Add entries to the `nav` section in `docs/generation/markdown/mkdocs.yml` diff --git a/docs/generation/markdown/assets/images/banner.svg b/docs/generation/markdown/assets/images/banner.svg new file mode 100644 index 0000000..f03df0a --- /dev/null +++ b/docs/generation/markdown/assets/images/banner.svg @@ -0,0 +1,591 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/generation/markdown/assets/images/icon_350x350.png b/docs/generation/markdown/assets/images/icon_350x350.png new file mode 100644 index 0000000..6e0b355 Binary files /dev/null and b/docs/generation/markdown/assets/images/icon_350x350.png differ diff --git a/docs/javascripts/mathjax.js b/docs/generation/markdown/assets/javascripts/mathjax.js similarity index 98% rename from docs/javascripts/mathjax.js rename to docs/generation/markdown/assets/javascripts/mathjax.js index 7800bbd..2f5bcd3 100644 --- a/docs/javascripts/mathjax.js +++ b/docs/generation/markdown/assets/javascripts/mathjax.js @@ -13,4 +13,4 @@ window.MathJax = { document$.subscribe(() => { MathJax.typesetPromise?.(); // ensure Promise exists to avoid type errors -}); \ No newline at end of file +}); diff --git a/docs/generation/markdown/assets/stylesheets/extra.css b/docs/generation/markdown/assets/stylesheets/extra.css new file mode 100644 index 0000000..72afd80 --- /dev/null +++ b/docs/generation/markdown/assets/stylesheets/extra.css @@ -0,0 +1,755 @@ +/* ============================================ + OPTIMIZED MKDOCS THEME - Maximum Content Utilization + ============================================ */ + +/* Core Color System - Refined for maximum readability */ +:root { + /* Primary palette */ + --md-primary-fg-color: #0f172a; + --md-primary-fg-color--light: #1e293b; + --md-primary-fg-color--dark: #020617; + + /* Accent colors - vibrant yet professional */ + --md-accent-fg-color: #3b82f6; + --md-accent-fg-color--hover: #2563eb; + --md-accent-fg-color--light: rgba(59, 130, 246, 0.08); + + /* Neutral colors for text hierarchy */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + + /* Surface colors */ + --surface-primary: #ffffff; + --surface-secondary: #f8fafc; + --surface-hover: #f1f5f9; + + /* Shadows for depth */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-accent: 0 4px 20px rgba(59, 130, 246, 0.25); + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ============================================ + LAYOUT OPTIMIZATION - Maximum Content Area + ============================================ */ + +/* Optimize the main grid to use available space */ +.md-grid { + max-width: none !important; + margin: 0 !important; +} + +/* Make sidebars narrower and more compact */ +.md-sidebar--primary { + width: 14rem !important; + left: 0; +} + +.md-sidebar--secondary { + width: 14rem !important; + right: 0; +} + +/* Optimize sidebar scrollwrap */ +.md-sidebar__scrollwrap { + margin: 0 !important; +} + +.md-sidebar__inner { + padding: 0.6rem 0.8rem !important; +} + +/* Expand content area significantly */ +.md-content { + max-width: none !important; + padding: 0 2.5rem !important; + width: auto !important; + flex-grow: 1 !important; +} + +.md-content__inner { + max-width: none !important; + width: 100% !important; + padding: 1.5rem 0 6rem 0 !important; + margin: 0 auto !important; +} + +/* Override the inline styles on the article/typeset container */ +.md-typeset { + max-width: none !important; + width: 100% !important; +} + +.md-content__inner.md-typeset { + max-width: none !important; + width: 100% !important; +} + +/* Use full width on larger screens */ +@media screen and (min-width: 76.25em) { + .md-content { + width: auto !important; + } + + .md-sidebar--primary { + width: 14rem !important; + } + + .md-sidebar--secondary { + width: 14rem !important; + } + + .md-content__inner { + width: 100% !important; + } +} + +/* Medium screens - balance sidebars and content */ +@media screen and (min-width: 60em) and (max-width: 76.24em) { + .md-sidebar--primary { + width: 12rem !important; + } + + .md-sidebar--secondary { + width: 12rem !important; + } + + .md-content { + width: auto !important; + } + + .md-content__inner { + width: 100% !important; + } +} + +/* Responsive collapse for smaller screens */ +@media screen and (max-width: 76.1875em) { + .md-sidebar--primary { + transform: translateX(-14.5rem) !important; + } + + [data-md-toggle="drawer"]:checked ~ .md-container .md-sidebar--primary { + transform: translateX(0) !important; + } + + .md-content { + margin-left: 0 !important; + width: 100% !important; + } + + .md-content__inner { + width: 100% !important; + } +} + +/* ============================================ + HEADER - Clean and Elegant + ============================================ */ +.md-header { + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + transition: box-shadow var(--transition-base); + position: sticky; + top: 0; + z-index: 4; +} + +.md-header--shadow { + box-shadow: var(--shadow-md); +} + +.md-header__inner { + padding: 0.4rem 1.5rem; + max-width: none; + margin: 0 auto; +} + +.md-header__button.md-icon { + color: #f8fafc; + opacity: 0.9; + transition: opacity var(--transition-fast), transform var(--transition-fast); +} + +.md-header__button.md-icon:hover { + opacity: 1; + transform: scale(1.05); +} + +/* Header title */ +.md-header__title { + font-weight: 600; + letter-spacing: -0.02em; + flex-grow: 0; +} + +/* ============================================ + NAVIGATION - Compact and Accessible + ============================================ */ + +/* Primary navigation */ +.md-nav--primary .md-nav__link { + color: #e2e8f0; + font-weight: 500; + transition: color var(--transition-fast); +} + +.md-nav--primary .md-nav__link:hover, +.md-nav--primary .md-nav__link:focus { + color: #3b82f6; +} + +/* Top-level nav items only - make them bold */ +.md-nav--primary > .md-nav__list > .md-nav__item > .md-nav__link { + font-weight: 700; +} + +/* Nested navigation - keep normal weight */ +.md-nav__item--nested > .md-nav__link { + font-weight: 600; + color: var(--text-primary) !important; +} + +/* Child items should NOT be bold */ +.md-nav__item--nested .md-nav .md-nav__link { + font-weight: 450 !important; +} + +/* Sidebar navigation - more compact */ +.md-sidebar { + background-color: transparent; +} + +.md-nav { + font-size: 0.75rem; + line-height: 1.4; +} + +.md-nav__title { + font-weight: 700; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + padding: 0.5rem 0.6rem 0.3rem; + margin-top: 1rem; +} + +.md-nav__title:first-child { + margin-top: 0; +} + +.md-nav__item { + padding: 0.05rem 0; +} + +.md-nav__link { + color: var(--text-secondary) !important; + padding: 0.4rem 0.6rem; + border-radius: 0.375rem; + font-weight: 450; + transition: all var(--transition-fast); + position: relative; + overflow: hidden; + font-size: 0.8rem; +} + +.md-nav__link::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--md-accent-fg-color); + transform: scaleY(0); + transition: transform var(--transition-base); + border-radius: 0 2px 2px 0; +} + +.md-nav__link:hover, +.md-nav__link:focus { + background-color: var(--surface-hover) !important; + color: var(--md-accent-fg-color) !important; +} + +.md-nav__link--active { + color: var(--md-accent-fg-color) !important; + background-color: var(--md-accent-fg-color--light) !important; + font-weight: 600; +} + +.md-nav__link--active::before { + transform: scaleY(1); +} + +/* Nested navigation */ +.md-nav__item--nested > .md-nav__link { + font-weight: 600; + color: var(--text-primary) !important; +} + +/* Secondary navigation (TOC) */ +.md-nav--secondary .md-nav__link { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; +} + +/* ============================================ + CONTENT AREA - Optimal Readability with More Space + ============================================ */ + +/* Typography hierarchy - optimized for wider layout */ +.md-content h1 { + font-weight: 800; + font-size: 2.75rem; + line-height: 1.2; + letter-spacing: -0.03em; + margin-bottom: 1.5rem; + color: var(--text-primary); + margin-top: 0; +} + +.md-content h2 { + font-weight: 700; + font-size: 2rem; + line-height: 1.3; + letter-spacing: -0.02em; + margin-top: 3rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.md-content h3 { + font-weight: 600; + font-size: 1.5rem; + margin-top: 2rem; + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +.md-content h4 { + font-weight: 600; + font-size: 1.25rem; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.md-content p { + line-height: 1.75; + margin-bottom: 1.25rem; + color: var(--text-secondary); + font-size: 1rem; + max-width: none; +} + +/* Lists with better spacing */ +.md-content ul, +.md-content ol { + margin-bottom: 1.25rem; + padding-left: 1.5rem; +} + +.md-content li { + margin-bottom: 0.5rem; + line-height: 1.7; + color: var(--text-secondary); +} + +/* Links - Subtle but noticeable */ +.md-content a { + color: var(--md-accent-fg-color); + text-decoration: none; + font-weight: 500; + border-bottom: 1px solid transparent; + transition: all var(--transition-fast); + position: relative; +} + +.md-content a:hover, +.md-content a:focus { + color: var(--md-accent-fg-color--hover); + border-bottom-color: var(--md-accent-fg-color--hover); +} + +/* ============================================ + BUTTONS - Modern and Engaging + ============================================ */ +.md-button, +.md-button--primary { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + font-weight: 600; + padding: 0.625rem 1.5rem; + border: none; + border-radius: 0.5rem; + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); + cursor: pointer; +} + +.md-button:hover, +.md-button:focus, +.md-button--primary:hover, +.md-button--primary:focus { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + box-shadow: var(--shadow-accent); + transform: translateY(-2px); +} + +.md-button:active, +.md-button--primary:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +/* ============================================ + SEARCH - Clean and Functional + ============================================ */ +.md-search__form { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 0.5rem; + transition: all var(--transition-base); +} + +.md-search__form:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +.md-search__input { + background-color: transparent; + color: #f8fafc; + border: none; + padding: 0.5rem 1rem; +} + +.md-search__input::placeholder { + color: #cbd5e1; + opacity: 0.7; +} + +.md-search__input:focus { + outline: 2px solid var(--md-accent-fg-color); + outline-offset: 2px; +} + +/* Search results */ +.md-search-result { + border-radius: 0.5rem; + overflow: hidden; +} + +.md-search-result__item { + border-bottom: 1px solid var(--surface-hover); + transition: background-color var(--transition-fast); +} + +.md-search-result__item:hover { + background-color: var(--surface-hover); +} + +/* ============================================ + CODE BLOCKS - Developer-Friendly with Better Width + ============================================ */ +.md-content code { + background-color: var(--surface-secondary); + color: #e11d48; + padding: 0.15em 0.35em; + border-radius: 0.25rem; + font-size: 0.875em; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + border: 1px solid #e2e8f0; +} + +.md-content pre { + background-color: var(--surface-secondary); + border-radius: 0.5rem; + padding: 1.25rem; + overflow-x: auto; + border: 1px solid #e2e8f0; + box-shadow: var(--shadow-sm); + margin: 1.5rem 0; +} + +.md-content pre code { + background-color: transparent; + color: inherit; + padding: 0; + border: none; + font-size: 0.875rem; + line-height: 1.6; +} + +/* Code block copy button */ +.md-clipboard { + opacity: 0.6; + transition: opacity var(--transition-fast); +} + +.md-clipboard:hover { + opacity: 1; +} + +/* ============================================ + TABLES - Organized and Full-Width + ============================================ */ +.md-content table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: var(--shadow-sm); + margin: 1.5rem 0; +} + +.md-content th { + background-color: var(--surface-secondary); + color: var(--text-primary); + font-weight: 600; + padding: 0.875rem 1rem; + text-align: left; + border-bottom: 2px solid #e2e8f0; + font-size: 0.875rem; +} + +.md-content td { + padding: 0.875rem 1rem; + border-bottom: 1px solid #f1f5f9; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.md-content tr:last-child td { + border-bottom: none; +} + +.md-content tr:hover { + background-color: var(--surface-hover); +} + +/* ============================================ + ADMONITIONS - Visual Hierarchy + ============================================ */ +.md-content .admonition { + border-left: 4px solid var(--md-accent-fg-color); + border-radius: 0.5rem; + padding: 1rem 1.25rem; + margin: 1.5rem 0; + background-color: var(--surface-secondary); + box-shadow: var(--shadow-sm); +} + +.md-content .admonition-title { + font-weight: 700; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-size: 0.9rem; +} + +/* ============================================ + FOOTER - Full Width (Fixed for all themes) + ============================================ */ +.md-footer { + margin-left: 0; + margin-right: 0; + background-color: #1e293b !important; +} + +.md-footer-meta { + background-color: #0f172a !important; +} + +.md-footer-meta__inner { + color: #cbd5e1 !important; +} + +.md-footer__link, +.md-footer__title, +.md-copyright { + color: #cbd5e1 !important; +} + +.md-footer__link:hover { + color: #f8fafc !important; +} + +.md-social__link { + color: #cbd5e1 !important; +} + +.md-social__link:hover { + color: #f8fafc !important; +} + +/* ============================================ + DARK MODE - Elegant Night Theme + ============================================ */ +[data-md-color-scheme="slate"] { + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-tertiary: #64748b; + --surface-primary: #0f172a; + --surface-secondary: #1e293b; + --surface-hover: #334155; + --md-accent-fg-color: #60a5fa; + --md-accent-fg-color--hover: #3b82f6; + --md-accent-fg-color--light: rgba(96, 165, 250, 0.1); +} + +[data-md-color-scheme="slate"] .md-header { + background: linear-gradient(135deg, #020617 0%, #0f172a 100%); + box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.05); +} + +[data-md-color-scheme="slate"] .md-nav__link { + color: var(--text-secondary); +} + +[data-md-color-scheme="slate"] .md-nav__link:hover, +[data-md-color-scheme="slate"] .md-nav__link:focus { + background-color: var(--surface-hover); + color: var(--md-accent-fg-color); +} + +[data-md-color-scheme="slate"] .md-nav__link--active { + color: var(--md-accent-fg-color); + background-color: var(--md-accent-fg-color--light); +} + +[data-md-color-scheme="slate"] .md-content code { + background-color: var(--surface-secondary); + color: #fda4af; + border-color: #334155; +} + +[data-md-color-scheme="slate"] .md-content pre { + background-color: var(--surface-secondary); + border-color: #334155; +} + +[data-md-color-scheme="slate"] .md-content table { + border-color: #334155; +} + +[data-md-color-scheme="slate"] .md-content th { + background-color: var(--surface-secondary); + border-bottom-color: #334155; +} + +[data-md-color-scheme="slate"] .md-content td { + border-bottom-color: #1e293b; +} + +[data-md-color-scheme="slate"] .md-search__form { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-md-color-scheme="slate"] .md-search__form:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +/* ============================================ + SCROLLBAR - Minimal and Polished + ============================================ */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--surface-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* ============================================ + RESPONSIVE REFINEMENTS + ============================================ */ +@media screen and (max-width: 76.1875em) { + .md-nav__title { + background-color: var(--surface-secondary); + } + + .md-content { + padding: 0 1.5rem !important; + } +} + +@media screen and (max-width: 60em) { + .md-content { + padding: 0 1rem !important; + } + + .md-content h1 { + font-size: 2rem; + } + + .md-content h2 { + font-size: 1.5rem; + } +} + +/* ============================================ + MICRO-INTERACTIONS - Delightful Details + ============================================ */ +.md-content a, +.md-nav__link, +.md-button { + will-change: transform; +} + +.md-tabs__link { + opacity: 0.85; + transition: opacity var(--transition-fast); +} + +.md-tabs__link:hover { + opacity: 1; +} + +/* Focus visible for accessibility */ +*:focus-visible { + outline: 2px solid var(--md-accent-fg-color); + outline-offset: 2px; + border-radius: 0.25rem; +} + +/* ============================================ + PRINT STYLES - Optimized for Documentation + ============================================ */ +@media print { + .md-sidebar, + .md-header, + .md-footer { + display: none; + } + + .md-content { + margin: 0 !important; + max-width: none !important; + } +} + +/* ============================================ + MATHJAX - Responsive Font Scaling + ============================================ */ +.MathJax { + font-size: calc(125% + (150% - 125%) * (100vw - 320px) / (1920px - 320px)) !important; +} diff --git a/docs/generation/markdown/decorators.md b/docs/generation/markdown/decorators.md deleted file mode 100644 index 2dcff31..0000000 --- a/docs/generation/markdown/decorators.md +++ /dev/null @@ -1,396 +0,0 @@ -# Decorators - Making Code Reactive - -Decorators in FynX provide the high-level API for creating reactive relationships in your application. They transform regular Python functions and classes into reactive components that automatically respond to state changes. - -## Overview - -FynX decorators fall into two main categories: - -- **Function Decorators**: Transform functions into reactive components (`@reactive`) -- **Class Decorators**: Make class attributes reactive (`@observable`) - -## Function Decorators - -### `@reactive` - Automatic Function Execution - -The `@reactive` decorator makes functions automatically execute whenever their observable dependencies change. It's perfect for side effects like UI updates, logging, and API calls. - -#### Basic Usage - -```python -from fynx import reactive, observable - -counter = observable(0) - -@reactive(counter) -def log_counter_changes(new_value): - print(f"Counter changed to: {new_value}") - -counter = 5 # Prints: "Counter changed to: 5" -counter = 10 # Prints: "Counter changed to: 10" -``` - -#### Multiple Dependencies - -```python -user_name = observable("Alice") -user_age = observable(30) - -@reactive(user_name, user_age) -def update_user_display(name, age): - print(f"User: {name}, Age: {age}") - -user_name = "Bob" # Triggers with current age -user_age = 31 # Triggers with current name -``` - -#### Store-Level Reactions - -React to any change in an entire store: - -```python -from fynx import Store, observable, reactive - -class UserStore(Store): - name = observable("Alice") - age = observable(30) - email = observable("alice@example.com") - -@reactive(UserStore) -def on_any_user_change(store_snapshot): - print(f"User data changed: {store_snapshot.name}, {store_snapshot.age}") - -UserStore.name = "Bob" # Triggers reaction -UserStore.email = "bob@example.com" # Also triggers reaction -``` - -#### Cleanup - -Reactive functions can be unsubscribed: - -```python -# The decorator returns the original function -# so you can unsubscribe later -unsubscribe_func = reactive(UserStore)(on_any_user_change) - -# Later, stop the reaction -UserStore.unsubscribe(on_any_user_change) -``` - -### Conditional Reactions with @reactive - -You can use `@reactive` with conditional observables to create event-driven reactions: - -```python -from fynx import reactive, observable - -count = observable(5) - -# Create a conditional observable -is_above_threshold = count >> (lambda c: c > 10) - -@reactive(is_above_threshold) -def on_threshold(is_above): - if is_above: - print("Count exceeded threshold!") - -count.set(15) # Triggers the reaction -count.set(8) # No reaction (condition not met) -count.set(12) # Triggers the reaction again -``` - -## Class Decorators - -### `@observable` - Reactive Class Attributes - -The `@observable` decorator makes class attributes reactive. It's used within Store classes to create observable properties. - -#### Basic Usage in Stores - -```python -from fynx import Store, observable - -class CounterStore(Store): - count = observable(0) # Reactive attribute - step = observable(1) # Another reactive attribute - -# Direct assignment triggers reactivity -CounterStore.count = 5 -CounterStore.step = 2 -``` - -#### Computed Properties - -While not a decorator itself, the `>>` operator (used with `observable` attributes) creates computed properties: - -```python -class CounterStore(Store): - count = observable(0) - doubled = count >> (lambda x: x * 2) # Computed property - -CounterStore.count = 5 -print(CounterStore.doubled) # 10 -``` - -## Decorator Patterns - -### When to Use Each Decorator - -| Decorator | Use Case | Triggers On | -|-----------|----------|-------------| -| `@reactive` | Side effects, UI updates, logging, conditional reactions | Every change to dependencies | -| `@observable` | Reactive state in classes | N/A (attribute decorator) | - -### Combining Decorators - -```python -from fynx import Store, observable, reactive - -class TodoStore(Store): - todos = observable([]) - filter_mode = observable("all") - - # Reactive: Update UI on any change - @reactive(todos, filter_mode) - def update_ui(todos_list, mode): - print(f"UI updated: {len(todos_list)} todos, filter: {mode}") - - # Conditional reactive: Only when todos exist and filter changes to "completed" - completed_filter = (todos >> (lambda t: len(t) > 0)) & (filter_mode >> (lambda f: f == "completed")) - @reactive(completed_filter) - def show_completion_message(should_show): - if should_show: - completed_count = len([t for t in TodoStore.todos if t["completed"]]) - print(f"🎉 {completed_count} todos completed!") -``` - -### Error Handling - -Decorators handle errors gracefully: - -```python -@reactive(some_observable) -def potentially_failing_reaction(value): - if value < 0: - raise ValueError("Negative values not allowed!") - print(f"Processed: {value}") - -# Errors in reactive functions don't break the reactive system -some_observable = -5 # Error logged, but reactivity continues -some_observable = 10 # Continues working: "Processed: 10" -``` - -### Performance Considerations - -#### Lazy vs Eager Execution - -- **Reactive methods** (`@reactive`): Execute eagerly when dependencies change -- **Computed properties**: Execute lazily when accessed - -```python -expensive_calc = observable(0) - -# Eager: Runs immediately when expensive_calc changes -@reactive(expensive_calc) -def eager_update(val): - slow_operation(val) # Runs immediately - -# Lazy: Only runs when result is accessed -lazy_result = expensive_calc >> (lambda val: slow_operation(val)) - -# lazy_result.value # Only runs slow_operation here -``` - -#### Memory Management - -Always clean up subscriptions: - -```python -class Component: - def __init__(self): - # Store subscription references for cleanup - self._cleanup = reactive(store)(self._on_change) - - def destroy(self): - # Clean up when component is destroyed - store.unsubscribe(self._on_change) -``` - -## Common Patterns - -### UI State Management - -```python -class UIStore(Store): - sidebar_open = observable(False) - modal_visible = observable(False) - loading = observable(False) - - @reactive(sidebar_open) - def update_layout(open_state): - if open_state: - print("📱 Adjusting layout for sidebar") - else: - print("📱 Restoring full-width layout") - - # Conditional reactive for loading state - @reactive(loading) - def handle_loading_state(is_loading): - if is_loading: - print("⏳ Showing loading spinner") - else: - print("✅ Hiding loading spinner") -``` - -### Form Handling - -```python -class FormStore(Store): - email = observable("") - password = observable("") - confirm_password = observable("") - - # Reactive validation - @reactive(email) - def validate_email(email_val): - if email_val and "@" not in email_val: - print("❌ Invalid email format") - - # Conditional reactive for complete form - form_valid = ( - email >> (lambda e: e and "@" in e) & - password >> (lambda p: len(p) >= 8) & - (password | confirm_password) >> (lambda p, c: p == c) - ) - @reactive(form_valid) - def enable_submit(is_valid): - if is_valid: - print("✅ Form is valid and ready to submit") -``` - -### API Integration - -```python -class ApiStore(Store): - is_loading = observable(False) - data = observable(None) - error = observable(None) - - @reactive(is_loading) - def update_ui_state(loading): - if loading: - print("⏳ Showing loading indicator") - else: - print("✅ Hiding loading indicator") - - # Conditional reactive for error state - @reactive(error) - def handle_error_state(error_val): - if error_val is not None: - print(f"❌ Error: {error_val}") - - # Conditional reactive for successful response - @reactive(data) - def process_successful_response(data_val): - if data_val is not None: - print(f"📦 Processing data: {len(data_val)} items") -``` - -## Best Practices - -### 1. Use Descriptive Function Names - -```python -# Good -@reactive(user_data) -def update_user_profile_display(user): - pass - -# Avoid -@reactive(user_data) -def func1(user): - pass -``` - -### 2. Keep Reactive Functions Focused - -```python -# Good: Single responsibility -@reactive(shopping_cart) -def update_cart_total(cart): - calculate_total(cart) - -@reactive(shopping_cart) -def update_cart_item_count(cart): - update_counter(len(cart)) - -# Avoid: Multiple responsibilities -@reactive(shopping_cart) -def handle_cart_changes(cart): - calculate_total(cart) - update_counter(len(cart)) - send_analytics(cart) - update_ui(cart) -``` - -### 3. Handle Errors Appropriately - -```python -@reactive(api_response) -def handle_api_response(response): - try: - if response["error"]: - show_error(response["error"]) - else: - process_data(response["data"]) - except Exception as e: - log_error(f"Failed to handle API response: {e}") - show_generic_error() -``` - -### 4. Use Conditional Observables for State Machines - -```python -current_state = observable("idle") - -# Create conditional observables for state transitions -is_loading = current_state >> (lambda s: s == "loading") -is_success = current_state >> (lambda s: s == "success") -is_error = current_state >> (lambda s: s == "error") - -@reactive(is_loading) -def enter_loading_state(is_loading_state): - if is_loading_state: - show_spinner() - -@reactive(is_success) -def enter_success_state(is_success_state): - if is_success_state: - hide_spinner() - show_success_message() - -@reactive(is_error) -def enter_error_state(is_error_state): - if is_error_state: - hide_spinner() - show_error_message() -``` - -### 5. Document Side Effects - -```python -@reactive(user_preferences) -def update_application_theme(preferences): - """ - Updates the application theme based on user preferences. - - Side effects: - - Modifies CSS custom properties - - Updates localStorage - - Triggers re-render of styled components - """ - apply_theme(preferences["theme"]) - save_to_localstorage("theme", preferences["theme"]) -``` - -Decorators are the bridge between reactive state and imperative code. They allow you to write declarative relationships while maintaining clean, maintainable code. Use `@reactive` for all reactive behavior, combining it with conditional observables for event-driven reactions. Remember that reactive functions should focus on side effects while computed properties handle pure transformations. diff --git a/docs/index.md b/docs/generation/markdown/index.md similarity index 87% rename from docs/index.md rename to docs/generation/markdown/index.md index 2979b1a..c24edff 100644 --- a/docs/index.md +++ b/docs/generation/markdown/index.md @@ -1,15 +1,13 @@ -
-

🚧 Documentation Under Construction

-

Hey! You got here early! FynX is still incredibly new, so we're still ironing out the documentation here. Thanks for your patience and early interest!

+
+

🚧 Documentation Under Construction

+

Hey! You got here early! FynX is still incredibly new, so we're still ironing out the documentation here. Thanks for your patience and early interest!

# FynX - -

- FynX Logo + FynX Logo

@@ -38,55 +36,39 @@ FynX is a lightweight reactive state management library for Python that brings the elegance of reactive programming to your applications. Inspired by MobX, FynX eliminates the complexity of manual state synchronization by automatically propagating changes through your application's data flow. When one piece of state changes, everything that depends on it updates automatically—no boilerplate, no explicit update calls, just transparent reactivity. +## Installation -## Understanding Reactive Programming - -Traditional imperative programming requires you to manually orchestrate updates: when data changes, you must explicitly call update methods, refresh UI components, or invalidate caches. This creates brittle, error-prone code where it's easy to forget an update or create inconsistent states. - -Reactive programming inverts this model. Instead of imperatively triggering updates, you declare relationships between data. When a value changes, the framework automatically propagates that change to everything that depends on it. Think of it like a spreadsheet: when you change a cell, all formulas referencing that cell recalculate automatically. FynX brings this same automatic dependency tracking and update propagation to Python. - -What makes FynX special is its transparency. You don't need to learn special syntax or wrap everything in framework-specific abstractions. Just use normal Python objects and assignment—FynX handles the reactivity behind the scenes through automatic dependency tracking. - - -## Core Concepts - -FynX's design centers on four fundamental building blocks that work together to create reactive data flows: - -### Observables - -Observables are the foundation of reactivity. An observable is simply a value that FynX watches for changes. When you modify an observable, FynX automatically notifies everything that depends on it. Think of observables as the source nodes in your application's dependency graph—they're the raw state that drives everything else. +Install FynX from PyPI using pip: -### Computed Values +```bash +pip install fynx +``` -Computed values are derived data that automatically recalculates when their dependencies change. They provide memoization by default, meaning they only recompute when one of their inputs actually changes—not on every access. This makes them both convenient and performant for expensive calculations. Computed values form the intermediate nodes in your dependency graph, transforming observables into the exact shape your application needs. +FynX has no required dependencies and works with Python 3.9 and above. -### Reactions -Reactions are side effects that execute automatically when their observed dependencies change. Use reactions for actions like updating a UI, making an API call, logging, or any other effect that should happen in response to state changes. While observables and computed values represent your data, reactions represent what your application does with that data. +## Why Use FynX? -### Stores +**Transparent Reactivity**: FynX requires no special syntax. Use standard Python assignment, method calls, and attribute access—reactivity works automatically without wrapper objects or proxy patterns. -Stores provide organizational structure by grouping related observables, computed values, and methods together. They offer convenient patterns for subscribing to changes and managing related state as a cohesive unit. Stores aren't required, but they help you organize complex state into logical, reusable components. +**Automatic Dependency Tracking**: FynX observables track their dependents automatically during execution. You never manually register or unregister dependencies; the framework infers them from how your code actually runs. -### Conditional Reactions +**Lazy Evaluation with Memoization**: Computed values only recalculate when their dependencies change, and only when accessed. This combines the convenience of automatic updates with the performance of intelligent caching. -Conditional reactions extend the basic reaction pattern by only executing when specific conditions are met. They're perfect for implementing state machines, validation rules, or any scenario where you need fine-grained control over when effects trigger. This allows you to express complex conditional logic declaratively rather than scattering imperative checks throughout your code. +**Composable Architecture**: Observables, computed values, and reactions compose naturally. You can nest stores, chain computed values, and combine reactions to build complex reactive systems from simple, reusable pieces. -## Why FynX? +**Expressive Operators**: FynX provides intuitive operators (`+`, `>>`, `&`, `~`, `|`) that let you compose reactive logic clearly and concisely, making your data flow explicit and easy to understand. -**Transparent Reactivity**: FynX requires no special syntax. Use standard Python assignment, method calls, and attribute access—reactivity works automatically without wrapper objects or proxy patterns. -**Automatic Dependency Tracking**: FynX observables track their dependents automatically during execution. You never manually register or unregister dependencies; the framework infers them from how your code actually runs. +## Understanding Reactive Programming -**Lazy Evaluation with Memoization**: Computed values only recalculate when their dependencies change, and only when accessed. This combines the convenience of automatic updates with the performance of intelligent caching. +Traditional imperative programming requires you to manually orchestrate updates: when data changes, you must explicitly call update methods, refresh UI components, or invalidate caches. This creates brittle, error-prone code where it's easy to forget an update or create inconsistent states. -**Full Type Safety**: FynX provides complete type hints, giving you autocomplete, inline documentation, and static analysis throughout your reactive code. +Reactive programming inverts this model. Instead of imperatively triggering updates, you declare relationships between data. When a value changes, the framework automatically propagates that change to everything that depends on it. Think of it like a spreadsheet: when you change a cell, all formulas referencing that cell recalculate automatically. FynX brings this same automatic dependency tracking and update propagation to Python. -**Memory Efficient**: FynX automatically cleans up reactive contexts when they're no longer needed, preventing memory leaks in long-running applications. +What makes FynX special is its transparency. You don't need to learn special syntax or wrap everything in framework-specific abstractions. Just use normal Python objects and assignment—FynX handles the reactivity behind the scenes through automatic dependency tracking. -**Composable Architecture**: Observables, computed values, and reactions compose naturally. You can nest stores, chain computed values, and combine reactions to build complex reactive systems from simple, reusable pieces. -**Expressive Operators**: FynX provides intuitive operators (`|`, `>>`, `&`, `~`) that let you compose reactive logic clearly and concisely, making your data flow explicit and easy to understand. ## Quick Start Example @@ -102,7 +84,7 @@ class UserStore(Store): is_online = observable(False) # Computed property that automatically updates when dependencies change - greeting = (name | age) >> ( + greeting = (name + age) >> ( lambda n, a: f"Hello, {n}! You are {a} years old." ) @@ -126,15 +108,31 @@ UserStore.is_online = True # Prints: Adult user Bob is now online! Notice how natural the code looks—no explicit update calls, no subscription management, just straightforward Python that happens to be reactive. -## Installation -Install FynX from PyPI using pip: +## Core Concepts -```bash -pip install fynx -``` +FynX's design centers on four fundamental building blocks that work together to create reactive data flows: + +### Observables + +Observables are the foundation of reactivity. An observable is simply a value that FynX watches for changes. When you modify an observable, FynX automatically notifies everything that depends on it. Think of observables as the source nodes in your application's dependency graph—they're the raw state that drives everything else. + +### Computed Values + +Computed values are derived data that automatically recalculates when their dependencies change. They provide memoization by default, meaning they only recompute when one of their inputs actually changes—not on every access. This makes them both convenient and performant for expensive calculations. Computed values form the intermediate nodes in your dependency graph, transforming observables into the exact shape your application needs. + +### Reactions + +Reactions are side effects that execute automatically when their observed dependencies change. Use reactions for actions like updating a UI, making an API call, logging, or any other effect that should happen in response to state changes. While observables and computed values represent your data, reactions represent what your application does with that data. + +### Stores + +Stores provide organizational structure by grouping related observables, computed values, and methods together. They offer convenient patterns for subscribing to changes and managing related state as a cohesive unit. Stores aren't required, but they help you organize complex state into logical, reusable components. + +### Conditional Reactions + +Conditional reactions extend the basic reaction pattern by only executing when specific conditions are met. They're perfect for implementing state machines, validation rules, or any scenario where you need fine-grained control over when effects trigger. This allows you to express complex conditional logic declaratively rather than scattering imperative checks throughout your code. -FynX has no required dependencies and works with Python 3.8 and above. ## Common Patterns @@ -148,7 +146,7 @@ As you work with FynX, you'll find these patterns emerge naturally: **Conditional Logic**: Use watch decorators to implement state machines, validation rules, or event filtering. This keeps conditional logic declarative and colocated with the relevant state. -**Data Flow Composition**: Use FynX's operators (`|` for piping values, `>>` for chaining, `&` for combining) to build clear, expressive data transformation pipelines. +**Data Flow Composition**: Use FynX's operators (`+` for piping values, `>>` for chaining, `&` for combining) to build clear, expressive data transformation pipelines. ## Documentation diff --git a/docs/generation/markdown/mathematical-foundations.md b/docs/generation/markdown/mathematical/mathematical-foundations.md similarity index 95% rename from docs/generation/markdown/mathematical-foundations.md rename to docs/generation/markdown/mathematical/mathematical-foundations.md index b9cd7f8..2e874fa 100644 --- a/docs/generation/markdown/mathematical-foundations.md +++ b/docs/generation/markdown/mathematical/mathematical-foundations.md @@ -101,10 +101,10 @@ You want to combine two separate time-varying values into a single time-varying first_name: Observable[str] = observable("Jane") last_name: Observable[str] = observable("Doe") -full_name_data: Observable[tuple[str, str]] = first_name | last_name +full_name_data: Observable[tuple[str, str]] = first_name + last_name ``` -The `|` operator creates this combination. But what exactly has happened here? Category theory gives us a precise answer: we've constructed a product. +The `+` operator creates this combination. But what exactly has happened here? Category theory gives us a precise answer: we've constructed a product. The product satisfies an elegant isomorphism: @@ -121,22 +121,22 @@ Formally, if you have any way of using two observables together, that usage fact In practice, this manifests in a useful way. Consider: ```python -product = first_name | last_name +product = first_name + last_name full_name = product >> (lambda t: f"{t[0]} {t[1]}") initials = product >> (lambda t: f"{t[0][0]}.{t[1][0]}.") display = product >> (lambda t: f"{t[1]}, {t[0]}") ``` -All three computations need both names. The universal property proves they're all talking about the *same* product. FynX computes `first_name | last_name` once, not three times. The optimizer can safely share this computation because the mathematics guarantees all three uses refer to the same mathematical object. +All three computations need both names. The universal property proves they're all talking about the *same* product. FynX computes `first_name + last_name` once, not three times. The optimizer can safely share this computation because the mathematics guarantees all three uses refer to the same mathematical object. ### Symmetry and Associativity Products have nice algebraic properties. They're symmetric—order doesn't affect the structure: ```python -ab = first_name | last_name # Observable[tuple[str, str]] -ba = last_name | first_name # Observable[tuple[str, str]] +ab = first_name + last_name # Observable[tuple[str, str]] +ba = last_name + first_name # Observable[tuple[str, str]] ``` These are isomorphic. Same information, different tuple order. The structure is preserved. @@ -146,8 +146,8 @@ They're also associative—grouping doesn't matter: ```python city = observable("New York") -left = (first_name | last_name) | city -right = first_name | (last_name | city) +left = (first_name + last_name) + city +right = first_name + (last_name + city) ``` The nesting differs, but structurally, all three observables are combined correctly. Changes to any propagate through as expected. @@ -193,7 +193,7 @@ Passenger Data ↓ [Passport Control] ← is_in_range check ↓ -[Security Screening] ← is_stable check +[Security Screening] ← is_stable check ↓ [Boarding Pass Validation] ← has_signal check ↓ @@ -209,7 +209,7 @@ When you write `data & condition1 & condition2`, you're building this chain of c ```python sensor_reading.set(42.5) # All conditions pass → gate opens sensor_reading.set(150) # Fails range check → gate closes -sensor_reading.set(-10) # Fails range check → gate stays closed +sensor_reading.set(-10) # Fails range check → gate stays closed sensor_reading.set(55.0) # All conditions pass → gate opens again ``` @@ -300,8 +300,8 @@ The pullback is a new object (often written $X \times_Z Y$) along with projectio ``` X ×_Z Y ----→ Y - | | - | | g + + + + + + g ↓ ↓ X ---f--→ Z ``` @@ -318,8 +318,8 @@ We're interested in values where all conditions map to `True`. Visually, for a s ``` ConditionalObservable ----→ {True} - | | - | π | + + + + + π + ↓ ↓ Source Observable ---c--→ Observable[Bool] ``` @@ -328,7 +328,7 @@ This is a pullback along the morphism selecting `True` from the boolean domain. For multiple conditions, we're taking the intersection of multiple such fibers: -$\text{ConditionalObservable}(s, c_1, \ldots, c_n) \cong \{ x \in s \mid c_1(x) \wedge c_2(x) \wedge \cdots \wedge c_n(x) \}$ +$$\text{ConditionalObservable}(s, c_1, \ldots, c_n) \cong \{ x \in s \mid c_1(x) \wedge c_2(x) \wedge \cdots \wedge c_n(x) \}$$ Each condition creates a checkpoint. The conditional observable represents values that clear all checkpoints—the pullback ensures this subset is well-defined categorically. @@ -394,7 +394,7 @@ is_valid_email = email >> (lambda e: "@" in e and "." in e) is_strong_password = password >> (lambda p: len(p) >= 8) # Product: combine related fields -passwords_match = (password | confirmation) >> (lambda pc: pc[0] == pc[1]) +passwords_match = (password + confirmation) >> (lambda pc: pc[0] == pc[1]) # Pullback: form is valid only when all conditions hold form_valid = email & is_valid_email & is_strong_password & passwords_match & terms_accepted @@ -430,7 +430,7 @@ The optimizer applies four types of rewrites, each justified by category theory. The composition law proves that sequential transformations can safely fuse: -$$\text{obs} \gg f \gg g \gg h \rightsquigarrow \text{obs} \gg (h \circ g \circ f)$$ +$$\text{obs} \gg f \gg g \gg h \rightarrow \text{obs} \gg (h \circ g \circ f)$$ Instead of creating intermediate observables for each `>>`, FynX fuses the entire chain into a single computed observable. This is why deep chains stay efficient—they're not actually thousands of separate observables, just composed functions in one observable. @@ -442,12 +442,12 @@ The universal property of products proves that multiple computations needing the ```python # User writes: -result1 = (a | b) >> f -result2 = (a | b) >> g -result3 = (a | b) >> h +result1 = (a + b) >> f +result2 = (a + b) >> g +result3 = (a + b) >> h # Optimizer produces: -product = a | b +product = a + b result1 = product >> f result2 = product >> g result3 = product >> h @@ -459,7 +459,7 @@ The product is computed once. When 47,000 components depend on a single product, The commutativity and associativity of Boolean pullbacks allow combining sequential filters: -$$\text{obs} \& c_1 \& c_2 \& c_3 \rightsquigarrow \text{obs} \& (c_1 \wedge c_2 \wedge c_3)$$ +$$\text{obs} \& c_1 \& c_2 \& c_3 \rightarrow \text{obs} \& (c_1 \wedge c_2 \wedge c_3)$$ Multiple conditional checks become one. The algebraic structure proves this fusion preserves semantics. @@ -467,7 +467,7 @@ Multiple conditional checks become one. The algebraic structure proves this fusi The optimizer decides whether to cache or recompute each node using a cost model: -$C(\sigma) = \alpha \cdot |\text{Dep}(\sigma)| + \beta \cdot \mathbb{E}[\text{Updates}(\sigma)] + \gamma \cdot \text{depth}(\sigma)$ +$$C(\sigma) = \alpha \cdot +\text{Dep}(\sigma)+ + \beta \cdot \mathbb{E}[\text{Updates}(\sigma)] + \gamma \cdot \text{depth}(\sigma)$$ This cost functional has important mathematical structure. It's a monoidal functor from the reactive category to the ordered monoid $(\mathbb{R}^+, +, 0)$. This means: @@ -557,7 +557,7 @@ This batching provides two benefits: each observable updates once per batch rath FynX's benchmark suite measures fundamental operations: - Observable creation: 794,000 ops/sec -- Individual updates: 353,000 ops/sec +- Individual updates: 353,000 ops/sec - Chain propagation: 1,640 ops/sec for 2,776-link chains - Reactive fan-out: 47,000 ops/sec with 47,427 dependent components @@ -593,6 +593,6 @@ We've explored how category theory provides foundations for reactive programming The mathematical foundations serve three purposes: they prove compositions work correctly, they show where optimizations preserve semantics, and they enable natural composition of observables. -Understanding these foundations isn't required to use FynX effectively. The `>>`, `|`, and `&` operators work intuitively without knowing category theory. But the mathematics explains why the library behaves as it does, why certain design decisions were made, and why optimizations are valid. +Understanding these foundations isn't required to use FynX effectively. The `>>`, `+`, and `&` operators work intuitively without knowing category theory. But the mathematics explains why the library behaves as it does, why certain design decisions were made, and why optimizations are valid. -Category theory transforms reactive programming from a collection of patterns into a structured system with provable properties. The elegance is that complexity becomes composability. Theory becomes tool. And mathematics guides engineering toward correctness. \ No newline at end of file +Category theory transforms reactive programming from a collection of patterns into a structured system with provable properties. The elegance is that complexity becomes composability. Theory becomes tool. And mathematics guides engineering toward correctness. diff --git a/docs/generation/markdown/reactive-decorator.md b/docs/generation/markdown/reactive-decorator.md deleted file mode 100644 index 4e3dc51..0000000 --- a/docs/generation/markdown/reactive-decorator.md +++ /dev/null @@ -1,5 +0,0 @@ -# @reactive Decorator - -Decorator for creating reactive functions that run when observables change. - -::: fynx.reactive diff --git a/docs/generation/markdown/api.md b/docs/generation/markdown/reference/api.md similarity index 88% rename from docs/generation/markdown/api.md rename to docs/generation/markdown/reference/api.md index bcb8fb4..f4b96eb 100644 --- a/docs/generation/markdown/api.md +++ b/docs/generation/markdown/reference/api.md @@ -1,7 +1,5 @@ # API Reference - - This reference provides comprehensive documentation of FynX's public API. FynX is a reactive programming library that makes your application state respond automatically to changes—think of it as a spreadsheet for your code, where updating one cell automatically recalculates all the formulas that depend on it. ## A Mental Model for FynX @@ -12,7 +10,7 @@ Before diving into the API details, it helps to understand FynX's core philosoph **FynX is declarative**: You describe *relationships* between values, and FynX handles the updates automatically. Change a value once, and everything that depends on it updates correctly, in the right order, every time. -This mental shift—from managing updates to declaring relationships—is the key to thinking reactively. +This mental shift—from managing updates to declaring relationships—is what makes thinking reactively so powerful. ## Your Learning Path @@ -33,15 +31,15 @@ Observables are containers for values that change over time. Unlike regular vari **[Observable](observable.md)** — The foundation of FynX. Create observables with `observable(initial_value)`, read them with `.value`, write them with `.set(new_value)`. Every other FynX feature builds on this simple primitive. -**[ComputedObservable](computed-observable.md)** — Values that automatically recalculate when their dependencies change. Create them with the `>>` operator: `full_name = (first | last) >> (lambda f, l: f"{f} {l}")`. The `>>` operator transforms observables through functions, creating a new computed observable. Alternatively, use the `.then(func)` method on observables for the same result. FynX tracks dependencies automatically and ensures computed values always stay up-to-date. +**[ComputedObservable](computed-observable.md)** — Values that automatically recalculate when their dependencies change. Create them with the `>>` operator: `full_name = (first + last) >> (lambda f, l: f"{f} {l}")`. The `>>` operator transforms observables through functions, creating a new computed observable. Alternatively, use the `.then(func)` method on observables for the same result. FynX tracks dependencies automatically and ensures computed values always stay up-to-date. -**[MergedObservable](merged-observable.md)** — Combine multiple observables into a single reactive tuple using the `|` operator: `position = x | y | z`. When any source changes, subscribers receive all values as a tuple. This is the foundation for reactive relationships that depend on multiple values. +**[MergedObservable](merged-observable.md)** — Combine multiple observables into a single reactive tuple using the `+` operator: `position = x + y + z`. When any source changes, subscribers receive all values as a tuple. This is the foundation for reactive relationships that depend on multiple values. **[ConditionalObservable](conditional-observable.md)** — Observables that emit when conditions are satisfied. Create them with the `&` operator: `valid_submission = form_data & is_valid`. This enables sophisticated reactive logic without cluttering your code with conditional checks. **[Observable Descriptors](observable-descriptors.md)** — The mechanism behind Store class attributes. When you write `name = observable("Alice")` in a Store class, you're creating a descriptor that provides clean property access without `.value` or `.set()`. -**[Observable Operators](observable-operators.md)** — The operators (`|`, `>>`, `&`, `~`) and methods (`.then()`, `.also()`) that let you compose observables into reactive pipelines. The `>>` operator is the primary way to transform observables, passing values through functions. Understanding these operators unlocks FynX's full expressive power. +**[Observable Operators](observable-operators.md)** — The operators (`+`, `>>`, `&`, `~`) and methods (`.then()`, `.requiring()`) that let you compose observables into reactive pipelines. The `>>` operator is the primary way to transform observables, passing values through functions. Understanding these operators unlocks FynX's full expressive power. ### Stores: Organizing State @@ -89,11 +87,11 @@ AppStore.count = current + 1 # Write ```python # Using the >> operator (recommended) doubled = count >> (lambda c: c * 2) -full_name = (first | last) >> (lambda f, l: f"{f} {l}") +full_name = (first + last) >> (lambda f, l: f"{f} {l}") # Using .then() method (alternative syntax) doubled = count.then(lambda c: c * 2) -full_name = (first | last).then(lambda f, l: f"{f} {l}") +full_name = (first + last).then(lambda f, l: f"{f} {l}") ``` ### Reacting to Changes @@ -119,7 +117,7 @@ def on_threshold(is_above): ```python # Merge multiple sources -position = x | y | z +position = x + y + z # Transform values with >> operator doubled = count >> (lambda c: c * 2) @@ -151,12 +149,12 @@ subtotal = ShoppingCartStore.items >> ( lambda items: sum(item['price'] * item['quantity'] for item in items) ) -discount_amount = (ShoppingCartStore.items | ShoppingCartStore.discount_code) >> ( +discount_amount = (ShoppingCartStore.items + ShoppingCartStore.discount_code) >> ( lambda items, code: sum(item['price'] * item['quantity'] for item in items) * 0.20 if code == "SAVE20" else 0.0 ) -total = (subtotal | discount_amount) >> ( +total = (subtotal + discount_amount) >> ( lambda sub, disc: sub - disc ) @@ -191,29 +189,34 @@ ShoppingCartStore.discount_code = "SAVE20" Throughout this reference, we follow consistent patterns: -- **Type signatures** use Python type hints for clarity and enable IDE autocomplete -- **Examples progress from simple to complex** within each page -- **Notes highlight gotchas** that trip up newcomers -- **Performance tips** appear when relevant to optimization decisions -- **See also links** connect related concepts and alternative approaches +* **Type signatures** use Python type hints for clarity and enable IDE autocomplete +* **Examples progress from simple to complex** within each page +* **Notes highlight gotchas** that trip up newcomers +* **Performance tips** appear when relevant to optimization decisions +* **See also links** connect related concepts and alternative approaches ## Navigating This Reference ### New to FynX? + Read in order: [Observable](observable.md) → [Store](store.md) → [@reactive](reactive-decorator.md) → [ConditionalObservable](conditional-observable.md) ### Building an application? + Focus on: [Store](store.md), [Observable Operators](observable-operators.md) (especially `>>`), [@reactive](reactive-decorator.md) ### Need complex state logic? + Dive into: [Observable Operators](observable-operators.md), [ConditionalObservable](conditional-observable.md), [@reactive](reactive-decorator.md) ### Performance optimization? + See: [ComputedObservable](computed-observable.md) for memoization, [Observable](observable.md) for subscription management ### Curious about implementation? + Explore: [Observable Descriptors](observable-descriptors.md) to understand how the magic works ---- +*** For conceptual introductions and tutorials, return to the [main documentation](../../index.md). diff --git a/docs/generation/markdown/computed-observable.md b/docs/generation/markdown/reference/computed-observable.md similarity index 83% rename from docs/generation/markdown/computed-observable.md rename to docs/generation/markdown/reference/computed-observable.md index 2d0815b..f820059 100644 --- a/docs/generation/markdown/computed-observable.md +++ b/docs/generation/markdown/reference/computed-observable.md @@ -58,7 +58,7 @@ result = count >> double >> add_one ## Key Properties -- **Reactive**: Automatically update when source observables change -- **Immutable**: Don't modify source values, create new derived values -- **Composable**: Can be chained and combined with other observables -- **Lazy**: Only compute when subscribed to or when source changes +* **Reactive**: Automatically update when source observables change +* **Immutable**: Don't modify source values, create new derived values +* **Composable**: Can be chained and combined with other observables +* **Lazy**: Only compute when subscribed to or when source changes diff --git a/docs/generation/markdown/conditional-observable.md b/docs/generation/markdown/reference/conditional-observable.md similarity index 82% rename from docs/generation/markdown/conditional-observable.md rename to docs/generation/markdown/reference/conditional-observable.md index 88ef758..1f229d8 100644 --- a/docs/generation/markdown/conditional-observable.md +++ b/docs/generation/markdown/reference/conditional-observable.md @@ -60,7 +60,7 @@ count.set(4) # Prints: Positive even: 4 ## Key Properties -- **Filtering**: Only emit values that satisfy all conditions -- **Reactive**: Automatically re-evaluate conditions when source changes -- **Composable**: Can be combined with other observables using `&`, `|`, and `>>` -- **Efficient**: Conditions are only evaluated when source values change +* **Filtering**: Only emit values that satisfy all conditions +* **Reactive**: Automatically re-evaluate conditions when source changes +* **Composable**: Can be combined with other observables using `&`, `+`, and `>>` +* **Efficient**: Conditions are only evaluated when source values change diff --git a/docs/generation/markdown/merged-observable.md b/docs/generation/markdown/reference/merged-observable.md similarity index 92% rename from docs/generation/markdown/merged-observable.md rename to docs/generation/markdown/reference/merged-observable.md index 379f30e..f86a2c0 100644 --- a/docs/generation/markdown/merged-observable.md +++ b/docs/generation/markdown/reference/merged-observable.md @@ -1,5 +1,5 @@ # MergedObservable -Observables that combine multiple values using the merge operator (`|`). +Observables that combine multiple values using the merge operator (`+`). ::: fynx.observable.merged diff --git a/docs/generation/markdown/observable-descriptors.md b/docs/generation/markdown/reference/observable-descriptors.md similarity index 100% rename from docs/generation/markdown/observable-descriptors.md rename to docs/generation/markdown/reference/observable-descriptors.md diff --git a/docs/generation/markdown/observable-operators.md b/docs/generation/markdown/reference/observable-operators.md similarity index 100% rename from docs/generation/markdown/observable-operators.md rename to docs/generation/markdown/reference/observable-operators.md diff --git a/docs/generation/markdown/observable.md b/docs/generation/markdown/reference/observable.md similarity index 100% rename from docs/generation/markdown/observable.md rename to docs/generation/markdown/reference/observable.md diff --git a/docs/generation/markdown/reference/reactive-decorator.md b/docs/generation/markdown/reference/reactive-decorator.md new file mode 100644 index 0000000..075cb8d --- /dev/null +++ b/docs/generation/markdown/reference/reactive-decorator.md @@ -0,0 +1,299 @@ +# Understanding the @reactive Decorator + +The `@reactive` decorator bridges your pure, functional data transformations with the messy, real world of side effects. Think of it as the membrane between your application's logic and everything outside it—the UI, the network, the file system, the console. + +## Starting Simple + +Let's see what reactive functions look like in practice: + +```python +from fynx import reactive, observable + +count = observable(0) + +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +count.set(5) # Prints: "Count: 5" +count.set(10) # Prints: "Count: 10" +``` + +The function runs automatically whenever `count` changes. You declare what should happen when data changes, and the framework handles the timing. + +## The Commitment: What You Gain and What You Give Up + +Once you decorate a function with `@reactive`, you're making a commitment. The function becomes automatic—it runs when its dependencies change. In exchange, you lose the ability to call it manually: + +```python +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +log_count(10) # Raises fynx.reactive.ReactiveFunctionWasCalled exception +``` + +This isn't an arbitrary restriction. It's protecting you from confusion. If you could call `log_count()` manually *and* have it trigger automatically, which version of the value is authoritative? The manual call or the reactive update? The framework eliminates this ambiguity by enforcing one mode at a time. + +You can always change your mind, though. Call `.unsubscribe()` to sever the reactive connection and return the function to normal, non-reactive behavior: + +```python +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +count.set(5) # Prints: "Count: 5" + +log_count.unsubscribe() # Severs the reactive connection + +count.set(10) # No output—the function is no longer reactive +log_count(15) # Prints: "Count: 15"—now works as a normal function +``` + +After unsubscribing, the function reverts to its original, non-reactive form. You can call it manually again, and it will no longer respond to changes in its former dependencies. + +## A Crucial Detail: Initial State and Change Semantics + +Here's something that might surprise you: when you create a reactive function, it doesn't fire immediately with the current value. It only fires when the value *changes*. + +```python +ready = observable(True) # Already true + +@reactive(ready) +def on_ready(value): + print(f"Ready: {value}") + +# Nothing prints yet, even though ready is True + +ready.set(False) # Prints: "Ready: False" +ready.set(True) # Prints: "Ready: True" +``` + +This behavior has deep roots in category theory—reactive functions form what's called a "pullback" in categorical semantics. The initial state isn't captured because you haven't pulled back through a change yet. You're observing the flow of changes, not the snapshot of current state. + +This matters enormously for initialization logic. If you need something to run immediately based on current state, you'll need to handle that separately. Reactive functions are about responding to transitions, not about reflecting static state. + +## Conditional Reactions: The MobX `when` Pattern + +Here's where things get powerful. You can combine observables with logical operators to create conditional reactions that only fire when specific conditions are met: + +```python +is_logged_in = observable(False) +has_data = observable(False) +is_loading = observable(True) +should_sync = observable(False) + +# React only when logged in AND has data AND NOT loading OR should sync +@reactive(is_logged_in & has_data & ~is_loading + should_sync) +def sync_to_server(should_run): + if should_run: + perform_sync() +``` + +The operators work as you'd expect: + +* `&` is logical AND +* `+` is logical OR +* `~` is logical NOT (negation) + +These create composite observables that emit values based on boolean logic applied to their constituent observables. The critical insight: the reaction still follows the change-only semantics. Even if your condition is `True` at the moment you attach the reactive function, it won't fire until something changes *and* the condition is met. + +```python +logged_in = observable(True) +verified = observable(True) + +# Even though both are already True, this doesn't fire yet +@reactive(logged_in & verified) +def enable_premium_features(both_true): + print("Premium features enabled") + +# Nothing printed yet + +logged_in.set(False) # Condition now False, triggers reaction +# Prints: "Premium features enabled" with value False + +verified.set(False) # Both False, triggers reaction +# Prints: "Premium features enabled" with value False + +logged_in.set(True) # One is True, one is False, triggers reaction +# Prints: "Premium features enabled" with value False + +verified.set(True) # Both True now, triggers reaction +# Prints: "Premium features enabled" with value True +``` + +This mirrors MobX's `when` behavior, but with more compositional flexibility. You're not limited to simple conditions—you can build arbitrarily complex boolean expressions that describe exactly when your side effect should consider running. + +## Multiple Dependencies Without Conditions + +Sometimes you just want a reaction to fire whenever any of several observables change, without boolean logic: + +```python +name = observable("Alice") +age = observable(30) + +# Derive a combined observable first +full_name = (name + age) >> (lambda n, a: f"{n} ({a} years old)") + +# Then react to changes in the derivation +@reactive(full_name) +def update_display(display_name): + print(f"Display: {display_name}") + +name.set("Bob") # Triggers with "Bob (30 years old)" +age.set(31) # Triggers with "Bob (31 years old)" +``` + +Notice the pattern: derive first, react second. The `+` operator here isn't doing boolean OR—it's combining observables into a tuple-like stream. The `>>` operator then transforms that stream. Only after you've created a derived observable do you attach the reaction. + +## The Core Insight: Where @reactive Belongs + +Here's the fundamental principle that makes reactive systems maintainable: **`@reactive` is for side effects, not for deriving state.** + +When you're tempted to use `@reactive`, ask yourself: "Am I computing a new value from existing data, or am I sending information outside my application?" If you're computing, you want `>>` or `+` operators. If you're communicating with the outside world, you want `@reactive`. + +This distinction creates what we call the "functional core, reactive shell" pattern. Your core is pure transformations—testable, predictable, composable. Your shell is reactions—the unavoidable side effects that make your application actually do something. + +Let's see this in a real example: + +```python +# ===== FUNCTIONAL CORE (Pure) ===== +class OrderCore(Store): + items = observable([]) + shipping_address = observable(None) + payment_method = observable(None) + is_processing = observable(False) + + # Pure derivations—no side effects anywhere + subtotal = items >> (lambda i: sum(x['price'] * x['qty'] for x in i)) + has_items = items >> (lambda i: len(i) > 0) + has_address = shipping_address >> (lambda a: a is not None) + has_payment = payment_method >> (lambda p: p is not None) + + # Boolean logic for conditions + can_checkout = (has_items & has_address & has_payment & ~is_processing) >> (lambda x: x) + + tax = subtotal >> (lambda s: s * 0.08) + total = (subtotal + tax) >> (lambda s, t: s + t) + +# ===== REACTIVE SHELL (Impure) ===== +@reactive(OrderCore.can_checkout) +def update_checkout_button(can_checkout): + button.disabled = not can_checkout + +@reactive(OrderCore.total) +def update_display(total): + render_total(f"${total:.2f}") + +# Only auto-save when we have items and aren't processing +@reactive(OrderCore.has_items & ~OrderCore.is_processing) +def auto_save(should_save): + if should_save: + save_to_db(OrderCore.to_dict()) +``` + +Notice how the core is entirely composed of derivations—values computed from other values. No database calls, no DOM manipulation, no network requests. These pure transformations are easy to test, easy to understand, and easy to change. + +The reactions appear only at the boundary. They're where your perfect functional world meets reality: updating a button's state, rendering to the screen, persisting to a database. The conditional operators let you express exactly when these side effects should occur, without polluting your core logic. + +## The Trap of Clever Reactions + +The biggest pitfall with `@reactive` is trying to be too clever. Three patterns consistently cause problems: + +**The infinite loop.** When a reaction modifies what it's watching, you've created a feedback cycle: + +```python +count = observable(0) + +@reactive(count) +def increment_forever(value): + count.set(value + 1) # Every change triggers another change +``` + +This is obvious in toy examples but can hide in real code when the dependency is indirect. The change semantics don't save you here—each change triggers the reaction, which causes another change, ad infinitum. + +**The hidden cache.** When reactions maintain their own state, you've split your application's state across two systems: + +```python +results_cache = {} + +@reactive(query) +def update_cache(query_string): + results_cache[query_string] = fetch_results(query_string) +``` + +Now you have to remember that `results_cache` exists and keep it synchronized. Better to make the cache itself observable and derive from it. + +**The sequential assumption.** When reactions depend on each other's execution order, you've created fragile coupling: + +```python +shared_list = [] + +@reactive(data) +def reaction_one(value): + shared_list.append(value) + +@reactive(data) +def reaction_two(value): + # Assumes reaction_one has already run + print(f"List has {len(shared_list)} items") +``` + +The second reaction assumes the first has already run. But that's an implementation detail, not a guarantee. If execution order changes, your code breaks silently. + +The fix for all three is the same: keep reactions independent and stateless. Let the observable system coordinate state. Keep reactions purely about effects. + +## Advanced Patterns: Conditional Guards and Cleanup + +The conditional operators shine when you need to guard expensive or sensitive operations: + +```python +user = observable(None) +has_permission = observable(False) +is_online = observable(False) + +# Only sync when user is logged in, has permission, and is online +@reactive(user & has_permission & is_online) +def sync_sensitive_data(should_sync): + if should_sync and user.get(): + api.sync_user_data(user.get().id) + +# Later, when you want to stop syncing entirely: +sync_sensitive_data.unsubscribe() +``` + +The unsubscribe mechanism becomes particularly important in cleanup scenarios. If your reactive function represents a resource that needs explicit teardown (like a WebSocket connection or a file handle), you can unsubscribe when you're done to prevent further reactions and then perform cleanup in the function itself. + +## Store-Level Reactions + +Stores collect related observables, and you can react to derived properties on stores just like standalone observables: + +```python +class UserStore(Store): + name = observable("Alice") + age = observable(30) + is_active = observable(True) + + user_summary = (name + age) >> (lambda n, a: f"{n}, {a}") + should_display = is_active & (age >> (lambda a: a >= 18)) + +@reactive(UserStore.user_summary) +def sync_to_server(summary): + api.post('/user/update', {'summary': summary}) + +@reactive(UserStore.should_display) +def toggle_profile_visibility(should_show): + profile_element.visible = should_show + +UserStore.name = "Bob" # Triggers first reaction +UserStore.age = 31 # Triggers both reactions +UserStore.is_active = False # Triggers second reaction only +``` + +The store becomes your functional core. The reactions watching it become your shell. This separation makes testing straightforward—test the store logic in isolation, mock the side effects in the reactions. + +*** + +**The Big Picture:** Use `@reactive` sparingly. Most of your code should be pure derivations using `>>`, `+`, `&`, and `~`. Reactions appear only at the edges, where your application must interact with something external. The conditional operators let you express exactly when these interactions should happen without mixing conditions into your business logic. When you find yourself reaching for `@reactive`, pause and ask: "Is this really a side effect, or am I just deriving new state?" That question alone will guide you toward cleaner, more maintainable reactive systems. + +::: fynx.reactive diff --git a/docs/generation/markdown/reference/store.md b/docs/generation/markdown/reference/store.md new file mode 100644 index 0000000..e9d99fa --- /dev/null +++ b/docs/generation/markdown/reference/store.md @@ -0,0 +1,5 @@ +# Store Class + +Container class for grouping observables and managing reactive state. + +::: fynx.store diff --git a/docs/generation/markdown/store.md b/docs/generation/markdown/store.md deleted file mode 100644 index 6e2a6c0..0000000 --- a/docs/generation/markdown/store.md +++ /dev/null @@ -1,5 +0,0 @@ -# Store Class & @observable Decorator - -Container class for grouping observables and the decorator for making class attributes reactive. - -::: fynx.store diff --git a/docs/generation/markdown/conditionals.md b/docs/generation/markdown/tutorial/conditionals.md similarity index 84% rename from docs/generation/markdown/conditionals.md rename to docs/generation/markdown/tutorial/conditionals.md index 73c8e6b..e9299c5 100644 --- a/docs/generation/markdown/conditionals.md +++ b/docs/generation/markdown/tutorial/conditionals.md @@ -25,7 +25,7 @@ This works, but it's verbose. You have to write filtering logic in every subscri ## The Solution: Conditional Observables -FynX gives you operators that create filtered, conditional observables. The key insight: separate the condition logic from the filtering operation. +FynX gives you operators that create filtered, conditional observables. The insight: separate the condition logic from the filtering operation. ```python temperature = observable(20) @@ -143,6 +143,63 @@ is_online.set(False) # is_offline becomes True, prints: "User went offline" is_online.set(True) # is_offline becomes False, no output ``` +## The | Operator: Logical OR + +The `|` operator creates logical OR conditions between boolean observables. It emits when ANY of the conditions is truthy: + +```python +is_error = observable(False) +is_warning = observable(True) +is_critical = observable(False) + +# Logical OR using | operator +needs_attention = is_error | is_warning | is_critical + +# Alternative using .either() method +needs_attention_alt = is_error.either(is_warning).either(is_critical) + +needs_attention.subscribe(lambda needs_attention: { + if needs_attention: + print("⚠️ System needs attention!") +}) + +# Updates automatically when any condition changes +is_error.set(True) # Prints: "⚠️ System needs attention!" +is_warning.set(False) # Still prints (is_error is True) +is_error.set(False) # Still prints (is_critical is False, but was True initially) +``` + +The `|` operator creates conditional observables that only emit when the OR result is truthy. If the initial OR result is falsy, it raises `ConditionalNeverMet`. + +### Combining OR with Other Operators + +You can combine `|` with `&` and `~` for complex logical expressions: + +```python +user_input = observable("") +is_admin = observable(False) +is_moderator = observable(True) + +# Create boolean conditions +has_input = user_input >> (lambda u: len(u) > 0) +has_permission = is_admin | is_moderator # OR condition +is_not_empty = has_input & (lambda h: h == True) # AND condition + +# Complex condition: user has input AND (is admin OR moderator) +can_submit = user_input & has_input & has_permission + +can_submit.subscribe(lambda can_submit: { + if can_submit is not None: + print("✅ User can submit") + else: + print("❌ User cannot submit") +}) + +user_input.set("Hello") # Prints: "✅ User can submit" +is_moderator.set(False) # Prints: "❌ User cannot submit" (no permission) +is_admin.set(True) # Prints: "✅ User can submit" (admin permission) +``` + ### Combining Negation with Filtering Create "everything except" patterns: @@ -402,11 +459,12 @@ form_valid = (email_ok & (lambda _: True)) & (password_ok & (lambda _: True)) Conditionals transform your reactive system from "process everything" to "process only what matters": -- **`&` operator**: Filter data streams based on predicates -- **`~` operator**: Invert boolean conditions -- **Performance**: Skip unnecessary computations -- **Clarity**: Separate filtering logic from reaction logic -- **Composition**: Combine conditions with other operators +* **`&` operator**: Filter data streams based on predicates +* **`|` operator**: Create logical OR conditions between boolean observables +* **`~` operator**: Invert boolean conditions +* **Performance**: Skip unnecessary computations +* **Clarity**: Separate filtering logic from reaction logic +* **Composition**: Combine conditions with other operators Think of conditionals as reactive filters. They let you create observables that only emit valuable data, reducing noise and improving performance. Combined with transformations (`>>`) and reactions (`@reactive`), they give you a complete toolkit for building sophisticated reactive applications. diff --git a/docs/generation/markdown/derived-observables.md b/docs/generation/markdown/tutorial/derived-observables.md similarity index 92% rename from docs/generation/markdown/derived-observables.md rename to docs/generation/markdown/tutorial/derived-observables.md index d08e7b6..4753660 100644 --- a/docs/generation/markdown/derived-observables.md +++ b/docs/generation/markdown/tutorial/derived-observables.md @@ -1,4 +1,4 @@ -# Derived Observables: Transforming Data with `.then()` and `>>` +# Transforming Data with `.then()` and `>>` Observables hold reactive values, and conditionals filter them. But what truly unlocks FynX's power is transformation—the ability to derive new values from existing ones automatically. @@ -74,7 +74,7 @@ def calculate_total(subtotal, tax, shipping): subtotal = cart_items.then(calculate_subtotal) tax = subtotal.then(calculate_tax) shipping = subtotal.then(calculate_shipping) -total = (subtotal | tax | shipping).then(calculate_total) +total = (subtotal + tax + shipping).then(calculate_total) # Subscribe to see results def print_subtotal(s): @@ -105,14 +105,15 @@ You declare what each value means in terms of others. Changes propagate automati Both `.then()` and `>>` create computed observables, but with slightly different syntax: -- **`.then()`**: `source_observable.then(transformation_function)` - Method syntax -- **`>>`**: `source_observable >> transformation_function` - Operator syntax +* **`.then()`**: `source_observable.then(transformation_function)` - Method syntax +* **`>>`**: `source_observable >> transformation_function` - Operator syntax Both approaches: -- Take the current value from the source observable -- Pass it to your transformation function immediately (eager evaluation) -- Wrap the result in a new observable -- Automatically re-run the transformation when the source changes + +* Take the current value from the source observable +* Pass it to your transformation function immediately (eager evaluation) +* Wrap the result in a new observable +* Automatically re-run the transformation when the source changes ```python numbers = observable([1, 2, 3]) @@ -171,15 +172,15 @@ def create_greeting(n): greeting_method = name.then(create_greeting) greeting_operator = name >> create_greeting -# Multiple observables (using | first) +# Multiple observables (using + first) first = observable("John") last = observable("Doe") def combine_names(first_name, last_name): return f"{first_name} {last_name}" -full_name_method = (first | last).then(combine_names) -full_name_operator = (first | last) >> combine_names +full_name_method = (first + last).then(combine_names) +full_name_operator = (first + last) >> combine_names ``` ### Return Values @@ -269,25 +270,25 @@ def format_expensive_message(total_and_is_expensive): total, is_exp = total_and_is_expensive return f"High-value order: ${total:.2f}" -# Use | to combine, then transform -discounted_total_method = (prices | discount_rate).then(calculate_discounted_total) -discounted_total_operator = (prices | discount_rate) >> calculate_discounted_total +# Use + to combine, then transform +discounted_total_method = (prices + discount_rate).then(calculate_discounted_total) +discounted_total_operator = (prices + discount_rate) >> calculate_discounted_total # Use & for conditions, then format is_expensive_method = discounted_total_method.then(is_expensive) is_expensive_operator = discounted_total_method >> is_expensive -expensive_message_method = (discounted_total_method | is_expensive_method).then(format_expensive_message) -expensive_message_operator = (discounted_total_method | is_expensive_operator) >> format_expensive_message +expensive_message_method = (discounted_total_method + is_expensive_method).then(format_expensive_message) +expensive_message_operator = (discounted_total_method + is_expensive_operator) >> format_expensive_message ``` ## Performance Characteristics Derived observables are lazy and efficient: -- **Memoization**: Results are cached until source values change -- **Selective Updates**: Only recalculates when dependencies actually change -- **No Redundant Work**: If a transformation result hasn't changed, downstream observers don't re-run +* **Memoization**: Results are cached until source values change +* **Selective Updates**: Only recalculates when dependencies actually change +* **No Redundant Work**: If a transformation result hasn't changed, downstream observers don't re-run ```python def slow_computation(data): @@ -368,11 +369,11 @@ def double_count(c): derived_method = count.then(double_count) # Pass count, not count.value derived_operator = count >> double_count # Pass count, not count.value -merged = first_name | last_name # Pass observables, not .value +merged = first_name + last_name # Pass observables, not .value filtered = items & is_valid # Pass observables, not .value ``` -The operators (`.then()`, `>>`, `|`, `&`, `~`) are designed to work with observables and maintain reactivity. When you pass `.value` to them, you're passing a static snapshot instead of a reactive stream. +The operators (`.then()`, `>>`, `+`, `&`, `~`) are designed to work with observables and maintain reactivity. When you pass `.value` to them, you're passing a static snapshot instead of a reactive stream. **Inside subscribers and reactive functions, `.value` is fine:** @@ -649,15 +650,15 @@ is_ready_operator = app_state >> is_ready_state Both `.then()` and `>>` transform FynX from a simple notification system into a powerful data transformation engine. You stop writing imperative update code and start declaring relationships: -- **From**: "When X changes, update Y, then update Z" -- **To**: "Y is a transformation of X, Z is a transformation of Y" +* **From**: "When X changes, update Y, then update Z" +* **To**: "Y is a transformation of X, Z is a transformation of Y" This declarative approach eliminates entire categories of bugs: -- **No stale data**: Derived values always reflect current source values -- **No forgotten updates**: The reactive graph handles all propagation -- **No manual synchronization**: Relationships are maintained automatically +* **No stale data**: Derived values always reflect current source values +* **No forgotten updates**: The reactive graph handles all propagation +* **No manual synchronization**: Relationships are maintained automatically -Combined with conditionals (`&`) and merging (`|`), derived observables give you a complete toolkit for building reactive data pipelines. You describe what your data should look like, and FynX ensures it stays that way. +Combined with conditionals (`&`) and merging (`+`), derived observables give you a complete toolkit for building reactive data pipelines. You describe what your data should look like, and FynX ensures it stays that way. The next step is organizing these reactive pieces into reusable units called **Stores**—the architectural pattern that brings everything together. diff --git a/docs/generation/markdown/observables.md b/docs/generation/markdown/tutorial/observables.md similarity index 88% rename from docs/generation/markdown/observables.md rename to docs/generation/markdown/tutorial/observables.md index 0ed8bf3..3586376 100644 --- a/docs/generation/markdown/observables.md +++ b/docs/generation/markdown/tutorial/observables.md @@ -186,7 +186,7 @@ base_price = observable(100) quantity = observable(2) # This creates a computed observable (we'll explore these deeply in the next section) -total = (base_price | quantity) >> (lambda price, qty: price * qty) +total = (base_price + quantity) >> (lambda price, qty: price * qty) total.subscribe(lambda t: print(f"Total: ${t}")) @@ -202,24 +202,24 @@ This is what observables enable: **declarative state management**. You describe Observables shine in situations where: -- **Multiple things depend on the same state** — One change needs to update several downstream systems -- **State changes frequently** — User interactions, real-time data, animated values -- **Dependencies are complex** — Value A depends on B and C, which depend on D and E -- **You want to avoid manual synchronization** — Eliminating update code reduces bugs +* **Multiple things depend on the same state** — One change needs to update several downstream systems +* **State changes frequently** — User interactions, real-time data, animated values +* **Dependencies are complex** — Value A depends on B and C, which depend on D and E +* **You want to avoid manual synchronization** — Eliminating update code reduces bugs Observables add overhead compared to plain variables. For simple scripts or one-off calculations, that overhead isn't worth it. But for interactive applications, data pipelines, or anything with non-trivial state management, observables pay for themselves quickly. - ## What's Next Observables are more than containers—they're nodes in a reactive graph. But standalone observables are just the beginning. The real power emerges when you learn to: -- **Transform observables** using the `>>` operator to create derived values that update automatically -- **Combine observables** using the `|` operator to work with multiple sources of data -- **Filter observables** using the `&` operator to apply conditional logic and control when data flows -- **Organize observables** into Stores for cleaner application architecture -- **Automate reactions** with decorators that eliminate subscription boilerplate +* **Transform observables** using the `>>` operator to create derived values that update automatically +* **Combine observables** using the `+` operator to work with multiple sources of data +* **Filter observables** using the `&` operator to apply conditional logic and control when data flows +* **Create logical OR conditions** using the `|` operator to combine boolean observables +* **Organize observables** into Stores for cleaner application architecture +* **Automate reactions** with decorators that eliminate subscription boilerplate Each of these builds on the foundation you've just learned. Observables are simple, but their composition creates sophisticated reactive systems. -The key insight to carry forward: **observables aren't just containers—they're nodes in a reactive graph**. When you change one node, effects ripple through the entire structure automatically. That's the power FynX gives you. +The insight to carry forward: **observables aren't just containers—they're nodes in a reactive graph**. When you change one node, effects ripple through the entire structure automatically. That's the power FynX gives you. diff --git a/docs/generation/markdown/stores.md b/docs/generation/markdown/tutorial/stores.md similarity index 93% rename from docs/generation/markdown/stores.md rename to docs/generation/markdown/tutorial/stores.md index 1e57797..46afa89 100644 --- a/docs/generation/markdown/stores.md +++ b/docs/generation/markdown/tutorial/stores.md @@ -34,7 +34,7 @@ CounterStore.count.subscribe(lambda c: print(f"Count: {c}")) CounterStore.count = 10 # Prints: "Count: 10" ``` -Notice the asymmetry: you read with direct access (`CounterStore.count`), but the value is still an observable. You can still subscribe to it, transform it with `>>`, merge it with `|`. The Store class uses Python descriptors to give you clean syntax while preserving all of observable's power. +Notice the asymmetry: you read with direct access (`CounterStore.count`), but the value is still an observable. You can still subscribe to it, transform it with `>>`, merge it with `+`. The Store class uses Python descriptors to give you clean syntax while preserving all of observable's power. ## Why Stores Matter @@ -160,7 +160,7 @@ Computed values memoize their results. After the first access, they return the c ## Combining Multiple Observables -Most computed values depend on more than one observable. Use the `|` operator to merge observables: +Most computed values depend on more than one observable. Use the `+` operator to merge observables: ```python class CartStore(Store): @@ -172,17 +172,17 @@ class CartStore(Store): ) # Merge subtotal and tax_rate - tax_amount = (subtotal | tax_rate) >> ( + tax_amount = (subtotal + tax_rate) >> ( lambda sub, rate: sub * rate ) # Merge subtotal and tax_amount - total = (subtotal | tax_amount) >> ( + total = (subtotal + tax_amount) >> ( lambda sub, tax: sub + tax ) ``` -The `|` operator creates a merged observable that emits a tuple. When you transform it with `>>`, the function receives one argument per observable: +The `+` operator creates a merged observable that emits a tuple. When you transform it with `>>`, the function receives one argument per observable: ```python CartStore.items = [{'name': 'Widget', 'price': 20, 'quantity': 1}] @@ -196,7 +196,7 @@ print(CartStore.tax_amount) # 2.0 (recalculated) print(CartStore.total) # 22.0 (recalculated) ``` -Any change to a merged observable triggers recomputation. This makes `|` perfect for values that need to coordinate multiple pieces of state. +Any change to a merged observable triggers recomputation. This makes `+` perfect for values that need to coordinate multiple pieces of state. ## Methods: Encapsulating State Changes @@ -296,12 +296,12 @@ class AnalyticsStore(Store): total = values >> (lambda v: sum(v)) # Level 2: Depends on count and total - mean = (total | count) >> ( + mean = (total + count) >> ( lambda t, c: t / c if c > 0 else 0 ) # Level 3: Depends on values and mean - variance = (values | mean | count) >> ( + variance = (values + mean + count) >> ( lambda vals, avg, n: ( sum((x - avg) ** 2 for x in vals) / (n - 1) if n > 1 else 0 ) @@ -340,7 +340,7 @@ class UserProfileStore(Store): is_premium = observable(False) # Computed: full name - full_name = (first_name | last_name) >> ( + full_name = (first_name + last_name) >> ( lambda first, last: f"{first} {last}".strip() ) @@ -358,13 +358,13 @@ class UserProfileStore(Store): is_adult = age >> (lambda a: a >= 18) # Computed: profile completeness - is_complete = (first_name | last_name | email | is_email_valid) >> ( + is_complete = (first_name + last_name + email + is_email_valid) >> ( lambda first, last, email_addr, email_valid: bool(first and last and email_addr and email_valid) ) # Computed: user tier - user_tier = (is_premium | is_complete) >> ( + user_tier = (is_premium + is_complete) >> ( lambda premium, complete: ( "Premium" if premium else "Complete" if complete else @@ -455,7 +455,7 @@ class UIStore(Store): ) # Depends on multiple observables from ThemeStore - css_vars = (ThemeStore.mode | ThemeStore.font_size) >> ( + css_vars = (ThemeStore.mode + ThemeStore.font_size) >> ( lambda mode, size: { '--bg': "#ffffff" if mode == "light" else "#1a1a1a", '--text': "#000000" if mode == "light" else "#ffffff", @@ -482,6 +482,7 @@ Each Store maintains its own domain, but computed values can reach across Store Use Stores when you have: **Related state that belongs together:** + ```python # Good: Cart-related state in CartStore class CartStore(Store): @@ -491,6 +492,7 @@ class CartStore(Store): ``` **State that needs derived values:** + ```python # Good: Computed values with their source state class FormStore(Store): @@ -499,10 +501,11 @@ class FormStore(Store): email_valid = email >> (lambda e: '@' in e) password_valid = password >> (lambda p: len(p) >= 8) - form_valid = (email_valid | password_valid) >> (lambda e, p: e and p) + form_valid = (email_valid + password_valid) >> (lambda e, p: e and p) ``` **State that needs encapsulated modification:** + ```python # Good: Methods that maintain invariants class AccountStore(Store): @@ -552,9 +555,9 @@ print(ChildStore.count) # 10 (completely separate) **Key Behavior:** Unlike standard Python inheritance where child classes share parent attributes, Store inheritance creates separate observable instances for each class. This ensures clean state isolation: -- `BaseStore.count` and `ChildStore.count` are completely independent -- Changes to one don't affect the other -- Each class maintains its own reactive state +* `BaseStore.count` and `ChildStore.count` are completely independent +* Changes to one don't affect the other +* Each class maintains its own reactive state **Explicit Overrides:** You can still override inherited observables: @@ -653,14 +656,14 @@ price = items >> (lambda items: sum(item['price'] for item in items)) Stores organize your reactive state into cohesive, testable units. They combine observables, computed values, and methods into structures that represent distinct domains of your application. -Key concepts: +Core concepts: -- **Stores group related observables** — Keep state that belongs together in the same Store -- **Observable descriptors enable clean syntax** — Read and write Store attributes naturally -- **The `>>` operator creates computed values** — Derived state updates automatically -- **The `|` operator merges observables** — Combine multiple sources for multi-input computations -- **Always create new values** — Never mutate observable contents in place -- **Methods encapsulate state changes** — Define clear APIs for modifying state -- **Stores can depend on other Stores** — Build modular applications with cross-Store relationships +* **Stores group related observables** — Keep state that belongs together in the same Store +* **Observable descriptors enable clean syntax** — Read and write Store attributes naturally +* **The `>>` operator creates computed values** — Derived state updates automatically +* **The `+` operator merges observables** — Combine multiple sources for multi-input computations +* **Always create new values** — Never mutate observable contents in place +* **Methods encapsulate state changes** — Define clear APIs for modifying state +* **Stores can depend on other Stores** — Build modular applications with cross-Store relationships -With Stores, you can build reactive applications that scale from simple counters to complex, multi-domain state management systems. The key is organization: each Store owns its domain, exposes a clean API, and lets FynX handle all the synchronization automatically. +With Stores, you can build reactive applications that scale from simple counters to complex, multi-domain state management systems. The secret is organization: each Store owns its domain, exposes a clean API, and lets FynX handle all the synchronization automatically. diff --git a/docs/generation/markdown/tutorial/using-reactive.md b/docs/generation/markdown/tutorial/using-reactive.md new file mode 100644 index 0000000..f8d2519 --- /dev/null +++ b/docs/generation/markdown/tutorial/using-reactive.md @@ -0,0 +1,824 @@ +# @reactive: Automatic Reactions to Change + +Observables hold state, and Stores organize it. But how do you actually respond when that state changes? How do you keep UI, databases, and external systems in sync? + +Right now, if you want to respond to changes, you write this: + +```python +count = observable(0) + +def log_count(value): + print(f"Count: {value}") + +count.subscribe(log_count) +``` + +This works. But as your application grows, subscription management becomes tedious: + +```python +# Subscriptions scattered everywhere +count.subscribe(update_ui) +count.subscribe(save_to_database) +count.subscribe(notify_analytics) +name.subscribe(update_greeting) +email.subscribe(validate_email) +(first_name + last_name).subscribe(update_display_name) + +# Later... did you remember to unsubscribe? +count.unsubscribe(update_ui) +# Wait, which function was subscribed to which observable? +``` + +You're back to manual synchronization, just with a different syntax. The subscriptions themselves become state you have to manage. + +There's a better way. + +## Introducing @reactive + +The `@reactive` decorator turns functions into automatic reactions. Instead of manually subscribing, you declare *what observables matter* and FynX handles the rest: + +```python +from fynx import observable, reactive + +count = observable(0) + +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +count.set(5) # Prints: "Count: 5" +count.set(10) # Prints: "Count: 10" +``` + +That's it. No manual subscription. No cleanup to remember. Just a declaration: "this function reacts to this observable." + +The decorator does two things: + +1. **Subscribes automatically** — No need to call `.subscribe()` +2. **Runs on every change** — Whenever the observable changes, the function runs with the new value + +This is the bridge from passive state management (observables and stores) to active behavior (side effects that respond to changes). + +## A Critical Detail: When Reactions Fire + +Here's something that might surprise you: when you create a reactive function, it doesn't fire immediately with the current value. It only fires when the value *changes*. + +```python +ready = observable(True) # Already true + +@reactive(ready) +def on_ready(value): + print(f"Ready: {value}") + +# Nothing prints yet, even though ready is True + +ready.set(False) # Prints: "Ready: False" +ready.set(True) # Prints: "Ready: True" +``` + +This behavior has deep roots in category theory—reactive functions form what's called a "pullback" in categorical semantics. The initial state isn't captured because you haven't pulled back through a change yet. You're observing the flow of changes, not the snapshot of current state. + +This matters enormously for initialization logic. If you need something to run immediately based on current state, you'll need to handle that separately, perhaps by calling the function once manually before decorating it, or by setting up your initial state in a way that triggers the reaction. Reactive functions are about responding to transitions, not about reflecting static state. + +Understanding this execution model is crucial: + +```python +count = observable(0) + +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +# At this point, log_count has NOT run yet - no initial trigger + +count.set(5) # log_count runs for the first time +# Output: "Count: 5" + +count.set(5) # Same value - does log_count run? +# Output: (no additional output - only runs when value actually changes) +``` + +The function runs every time `.set()` is called with a different value—only when the value actually changes. The execution is synchronous—the function completes before `.set()` returns. This makes reactive code predictable and debuggable. When you write `count.set(5)`, you know that all reactive functions have finished by the time the next line runs. + +## The Mental Model: Declarative Side Effects + +Traditional programming separates "doing" from "reacting": + +```python +# Traditional: Manual coordination +def update_count(new_value): + count = new_value + update_ui(count) # Remember to call this + save_to_database(count) # Remember to call this + log_change(count) # Remember to call this +``` + +Every time you modify state, you must remember all the dependent actions. Miss one and your application falls out of sync. + +With `@reactive`, you declare the relationships once: + +```python +# Reactive: Declare what should happen +@reactive(count) +def update_ui(value): + print(f"UI: {value}") + +@reactive(count) +def save_to_database(value): + print(f"Saving: {value}") + +@reactive(count) +def log_change(value): + print(f"Log: {value}") + +# Now just update state +count.set(42) +# All three functions run automatically +# UI: 42 +# Saving: 42 +# Log: 42 +``` + +You've moved from "remember to update everything" to "declare what should stay synchronized." The burden of coordination shifts from you to FynX. + +## Conditional Reactions: Boolean Logic for When to React + +Here's where `@reactive` becomes truly powerful. You can combine observables with logical operators to create conditional reactions that only fire when specific conditions are met: + +```python +is_logged_in = observable(False) +has_data = observable(False) +is_loading = observable(True) +should_sync = observable(False) + +# React only when logged in AND has data AND NOT loading OR should sync +@reactive(is_logged_in & has_data & ~is_loading + should_sync) +def sync_to_server(should_run): + if should_run: + perform_sync() +``` + +The operators work exactly as you'd expect: + +* `&` is logical AND +* `+` is logical OR (when used with observables on both sides) +* `~` is logical NOT (negation) + +These create composite observables that emit values based on boolean logic. The critical insight: the reaction still follows the change-only semantics. Even if your condition is `True` when you attach the reactive function, it won't fire until something changes *and* the condition evaluates. + +```python +logged_in = observable(True) +verified = observable(True) + +# Even though both are already True, this doesn't fire yet +@reactive(logged_in & verified) +def enable_premium_features(both_true): + print(f"Premium features: {both_true}") + +# Nothing printed yet - waiting for first change + +logged_in.set(False) # Condition now False, triggers reaction +# Prints: "Premium features: False" + +verified.set(False) # Both False, triggers reaction +# Prints: "Premium features: False" + +logged_in.set(True) # One is True, one is False, triggers reaction +# Prints: "Premium features: False" + +verified.set(True) # Both True now, triggers reaction +# Prints: "Premium features: True" +``` + +This mirrors MobX's `when` behavior, but with more compositional flexibility. You're not limited to simple conditions—you can build arbitrarily complex boolean expressions that describe exactly when your side effect should consider running. + +Think of it as event-driven reactions with declarative conditions. Instead of checking conditions inside your reaction function, you express them in the observable composition itself: + +```python +# Instead of this: +@reactive(status) +def maybe_sync(status): + if status.logged_in and status.has_data and not status.loading: + perform_sync() + +# You can write this: +@reactive(logged_in & has_data & ~is_loading) +def sync_when_ready(should_sync): + if should_sync: + perform_sync() +``` + +The second version is clearer about *when* the sync happens—the condition is part of the observable dependency declaration, not buried in the function body. + +## Reacting to Multiple Observables + +Most real-world reactions depend on multiple pieces of state. When you need values from several observables without boolean logic, use the `+` operator differently—for combining observables into value tuples: + +```python +first_name = observable("Alice") +last_name = observable("Smith") + +# Derive a combined observable first +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") + +# Then react to changes in the derivation +@reactive(full_name) +def update_display(display_name): + print(f"Display: {display_name}") + +# Nothing prints yet - waiting for first change + +first_name.set("Bob") # Triggers with "Bob Smith" +last_name.set("Jones") # Triggers with "Bob Jones" +``` + +Notice the pattern: derive first, react second. The `+` operator combines observables into a stream of value pairs. The `>>` operator transforms that stream. Only after you've created a derived observable do you attach the reaction. + +This is a fundamental principle: most of the time, you don't react directly to raw observables. You react to *derived* observables—computed values that represent the meaningful state for your side effect. + +```python +class CartStore(Store): + items = observable([]) + tax_rate = observable(0.08) + +# Derive the meaningful state +total = (CartStore.items + CartStore.tax_rate) >> ( + lambda items, rate: sum(item['price'] * item['qty'] for item in items) * (1 + rate) +) + +# React to the derived state +@reactive(total) +def update_total_display(total_amount): + print(f"Total: ${total_amount:.2f}") +``` + +The reaction only cares about the final computed total, not about whether items changed or tax rate changed. This separation of concerns—derive meaning, then react to it—keeps your reactive functions simple and focused. + +## Reacting to Entire Stores + +Sometimes you want to react to *any* change in a Store, regardless of which specific observable changed. Pass the Store class itself: + +```python +class UserStore(Store): + name = observable("Alice") + age = observable(30) + email = observable("alice@example.com") + +@reactive(UserStore) +def sync_to_server(store_snapshot): + print(f"Syncing: {store_snapshot.name}, {store_snapshot.email}") + +# Doesn't run immediately - waits for first change + +UserStore.name = "Bob" # Triggers reaction +# Prints: "Syncing: Bob, alice@example.com" + +UserStore.age = 31 # Triggers reaction +# Prints: "Syncing: Bob, alice@example.com" + +UserStore.email = "bob@example.com" # Triggers reaction +# Prints: "Syncing: Bob, bob@example.com" +``` + +The function receives a snapshot of the entire Store. This is perfect for operations that need to consider the complete state—saving to a database, logging changes, synchronizing with a server. + +Note the subtle difference: when reacting to individual observables, you get the *values* as arguments. When reacting to a Store, you get the *Store snapshot itself* as a single argument, and you access observables through it. + +## Reacting to Computed Observables + +Everything that's an observable—including computed ones—works with `@reactive`: + +```python +class CartStore(Store): + items = observable([]) + +# Computed observable +item_count = CartStore.items >> (lambda items: len(items)) + +@reactive(item_count) +def update_badge(count): + print(f"Cart badge: {count}") + +# Doesn't run immediately - waits for first change + +CartStore.items = [{'name': 'Widget', 'price': 10}] +# Computed value recalculates: 1 +# Reactive function runs: "Cart badge: 1" + +CartStore.items = CartStore.items + [{'name': 'Gadget', 'price': 15}] +# Computed value recalculates: 2 +# Reactive function runs: "Cart badge: 2" +``` + +You don't react to `CartStore.items` directly. You react to the *computed* value. This is powerful: it means you only care about changes in the *derived* state, not every modification to the underlying data. + +If the computed value doesn't change, the reaction doesn't fire: + +```python +items = observable([1, 2, 3]) +length = items >> (lambda i: len(i)) + +@reactive(length) +def log_length(l): + print(f"Length: {l}") + +items.set([4, 5, 6]) # Length is still 3, reaction doesn't fire +items.set([7, 8, 9, 10]) # Length is now 4, reaction fires +``` + +This is exactly what you want—reactions tied to semantic meaning, not raw data changes. + +## The Commitment: What You Gain and What You Give Up + +Once you decorate a function with `@reactive`, you're making a commitment. The function becomes automatic—it runs when its dependencies change. In exchange, you lose the ability to call it manually: + +```python +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +log_count(10) # Raises fynx.reactive.ReactiveFunctionWasCalled exception +``` + +This isn't an arbitrary restriction. It's protecting you from confusion. If you could call `log_count()` manually *and* have it trigger automatically, which version of the value is authoritative? The manual call or the reactive update? The framework eliminates this ambiguity by enforcing one mode at a time. + +You can always change your mind, though. Call `.unsubscribe()` to sever the reactive connection and return the function to normal, non-reactive behavior: + +```python +@reactive(count) +def log_count(value): + print(f"Count: {value}") + +count.set(5) # Prints: "Count: 5" + +log_count.unsubscribe() # Severs the reactive connection + +count.set(10) # No output—the function is no longer reactive +log_count(15) # Prints: "Count: 15"—now works as a normal function +``` + +After unsubscribing, the function reverts to its original, non-reactive form. You can call it manually again, and it will no longer respond to changes in its former dependencies. + +This lifecycle management is particularly important for component-based architectures: + +```python +class UIComponent: + def __init__(self): + self.count = observable(0) + + @reactive(self.count) + def update_display(value): + print(f"Display: {value}") + + self._update_display = update_display + + def destroy(self): + # Clean up when component is destroyed + self._update_display.unsubscribe() +``` + +The pattern is simple: create reactive functions when you need them, unsubscribe when you're done. This prevents memory leaks and ensures reactions don't outlive the components they serve. + +## Practical Example: Form Validation + +Here's where `@reactive` really shines—coordinating complex UI behavior: + +```python +class FormStore(Store): + email = observable("") + password = observable("") + confirm_password = observable("") + +# Computed validations +email_valid = FormStore.email >> ( + lambda e: '@' in e and '.' in e.split('@')[-1] +) + +password_valid = FormStore.password >> ( + lambda p: len(p) >= 8 +) + +passwords_match = (FormStore.password + FormStore.confirm_password) >> ( + lambda pwd, confirm: pwd == confirm and pwd != "" +) + +form_valid = (email_valid & password_valid & passwords_match) >> (lambda x: x) + +# Reactive UI updates +@reactive(email_valid) +def update_email_indicator(is_valid): + status = "✓" if is_valid else "✗" + print(f"Email: {status}") + +@reactive(password_valid) +def update_password_indicator(is_valid): + status = "✓" if is_valid else "✗" + print(f"Password strength: {status}") + +@reactive(passwords_match) +def update_match_indicator(match): + status = "✓" if match else "✗" + print(f"Passwords match: {status}") + +@reactive(form_valid) +def update_submit_button(is_valid): + state = "enabled" if is_valid else "disabled" + print(f"Submit button: {state}") + +# Reactive functions don't run immediately +# Now update the form fields: +FormStore.email = "alice@example.com" +# Email: ✓ (email indicator runs) + +FormStore.password = "secure123" +# Password strength: ✓ (password indicator runs) +# Passwords match: ✗ (match indicator runs - passwords don't match yet) + +FormStore.confirm_password = "secure123" +# Passwords match: ✓ (match indicator runs) +# Submit button: enabled (form becomes valid) +``` + +Every UI element updates automatically in response to the relevant state changes. You never write "when email changes, check if it's valid and update the indicator." You just declare the relationship and FynX handles the orchestration. + +Notice how we use the `&` operator to create `form_valid`—it only becomes true when all three conditions are met. The reactive function on `form_valid` only fires when something changes, giving you precise control over when the submit button updates. + +## The Core Insight: Where @reactive Belongs + +Here's the fundamental principle that makes reactive systems maintainable: **`@reactive` is for side effects, not for deriving state.** + +When you're tempted to use `@reactive`, ask yourself: "Am I computing a new value from existing data, or am I sending information outside my application?" If you're computing, you want `>>`, `+`, `&`, or `~` operators. If you're communicating with the outside world, you want `@reactive`. + +This distinction creates what we call the "functional core, reactive shell" pattern. Your core is pure transformations—testable, predictable, composable. Your shell is reactions—the unavoidable side effects that make your application actually do something. + +```python +# ===== FUNCTIONAL CORE (Pure) ===== +class OrderCore(Store): + items = observable([]) + shipping_address = observable(None) + payment_method = observable(None) + is_processing = observable(False) + + # Pure derivations—no side effects anywhere + subtotal = items >> (lambda i: sum(x['price'] * x['qty'] for x in i)) + has_items = items >> (lambda i: len(i) > 0) + has_address = shipping_address >> (lambda a: a is not None) + has_payment = payment_method >> (lambda p: p is not None) + + # Boolean logic for conditions + can_checkout = (has_items & has_address & has_payment & ~is_processing) >> (lambda x: x) + + tax = subtotal >> (lambda s: s * 0.08) + total = (subtotal + tax) >> (lambda s, t: s + t) + +# ===== REACTIVE SHELL (Impure) ===== +@reactive(OrderCore.can_checkout) +def update_checkout_button(can_checkout): + button.disabled = not can_checkout + +@reactive(OrderCore.total) +def update_display(total): + render_total(f"${total:.2f}") + +# Only auto-save when we have items and aren't processing +@reactive(OrderCore.has_items & ~OrderCore.is_processing) +def auto_save(should_save): + if should_save: + save_to_db(OrderCore.to_dict()) +``` + +Notice how the core is entirely composed of derivations—values computed from other values. No database calls, no DOM manipulation, no network requests. These pure transformations are easy to test, easy to understand, and easy to change. + +The reactions appear only at the boundary. They're where your perfect functional world meets reality: updating a button's state, rendering to the screen, persisting to a database. The conditional operators let you express exactly when these side effects should occur, without polluting your core logic. + +## Best Practices for @reactive + +### Use @reactive for Side Effects Only + +**✅ USE @reactive for:** + +**Side Effects at Application Boundaries** + +```python +@reactive(UserStore) +def sync_to_server(store): + # Network I/O + api.post('/user/update', store.to_dict()) + +@reactive(settings_changed) +def save_to_local_storage(settings): + # Browser storage I/O + localStorage.setItem('settings', json.dumps(settings)) +``` + +**UI Updates** (DOM manipulation, rendering) + +```python +@reactive(cart_total) +def update_total_display(total): + # DOM manipulation + render_total(f"${total:.2f}") +``` + +**Logging and Monitoring** + +```python +@reactive(error_observable) +def log_errors(error): + # Logging side effect + logger.error(f"Application error: {error}") + analytics.track('error', {'message': str(error)}) +``` + +**Cross-System Coordination** (when one reactive system needs to update another) + +```python +@reactive(ThemeStore.dark_mode) +def update_editor_theme(is_dark): + # Coordinating separate systems + EditorStore.theme = 'dark' if is_dark else 'light' +``` + +**❌ AVOID @reactive for:** + +**Deriving State** (use `>>`, `+`, `&`, `~` instead) + +```python +# BAD: Using @reactive for transformation +@reactive(count) +def doubled_count(value): + doubled.set(value * 2) # Modifying another observable + +# GOOD: Use functional transformation +doubled = count >> (lambda x: x * 2) +``` + +**State Coordination** (use computed observables) + +```python +# BAD: Coordinating state in reactive functions +@reactive(first_name, last_name) +def update_full_name(first, last): + full_name.set(f"{first} {last}") + +# GOOD: Express as derived state +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") +``` + +**Business Logic** (keep logic in pure transformations) + +```python +# BAD: Business logic in reactive function +@reactive(order_items) +def calculate_total(items): + total = sum(item.price * item.quantity for item in items) + tax = total * 0.08 + order_total.set(total + tax) + +# GOOD: Business logic in pure transformation +order_total = order_items >> (lambda items: + sum(i.price * i.quantity for i in items) * 1.08 +) +``` + +### Anti-Patterns to Avoid + +**The infinite loop.** When a reaction modifies what it's watching, you've created a feedback cycle: + +```python +count = observable(0) + +@reactive(count) +def increment_forever(value): + count.set(value + 1) # Every change triggers another change +``` + +This is obvious in toy examples but can hide in real code when the dependency is indirect. The change semantics don't save you here—each change triggers the reaction, which causes another change, ad infinitum. + +**The hidden cache.** When reactions maintain their own state, you've split your application's state across two systems: + +```python +results_cache = {} + +@reactive(query) +def update_cache(query_string): + results_cache[query_string] = fetch_results(query_string) +``` + +Now you have to remember that `results_cache` exists and keep it synchronized. Better to make the cache itself observable and derive from it. + +**The sequential assumption.** When reactions depend on each other's execution order, you've created fragile coupling: + +```python +shared_list = [] + +@reactive(data) +def reaction_one(value): + shared_list.append(value) + +@reactive(data) +def reaction_two(value): + # Assumes reaction_one has already run + print(f"List has {len(shared_list)} items") +``` + +The second reaction assumes the first has already run. But that's an implementation detail, not a guarantee. If execution order changes, your code breaks silently. + +The fix for all three is the same: keep reactions independent and stateless. Let the observable system coordinate state. Keep reactions purely about effects. + +## Advanced Patterns: Conditional Guards and Cleanup + +The conditional operators shine when you need to guard expensive or sensitive operations: + +```python +user = observable(None) +has_permission = observable(False) +is_online = observable(False) + +# Only sync when user is logged in, has permission, and is online +@reactive(user & has_permission & is_online) +def sync_sensitive_data(should_sync): + if should_sync and user.get(): + api.sync_user_data(user.get().id) + +# Later, when you want to stop syncing entirely: +sync_sensitive_data.unsubscribe() +``` + +The unsubscribe mechanism becomes particularly important in cleanup scenarios. If your reactive function represents a resource that needs explicit teardown (like a WebSocket connection or a file handle), you can unsubscribe when you're done to prevent further reactions and then perform cleanup in the function itself. + +## @reactive vs. Manual Subscriptions + +When should you use `@reactive` instead of calling `.subscribe()` directly? + +**Use `@reactive` when:** + +You want declarative, self-documenting code: + +```python +@reactive(user_count) +def update_dashboard(count): + print(f"Users: {count}") +``` + +You're defining reactions at module level or class definition: + +```python +class UIController: + @reactive(AppStore.mode) + def sync_mode(mode): + update_ui_mode(mode) +``` + +You need lifecycle management with unsubscribe capability: + +```python +@reactive(data_stream) +def process_data(data): + handle_data(data) + +# Later, when component is destroyed +process_data.unsubscribe() # Clean up +``` + +**Use `.subscribe()` when:** + +You need dynamic subscriptions that change at runtime: + +```python +if user_wants_notifications: + count.subscribe(send_notification) +``` + +You need to unsubscribe conditionally: + +```python +subscription_func = count.subscribe(handler) +if some_condition: + count.unsubscribe(subscription_func) +``` + +You're building a library that accepts observables: + +```python +def create_widget(data_observable): + data_observable.subscribe(widget.update) +``` + +The rule of thumb: `@reactive` for static, declarative reactions with optional cleanup via `.unsubscribe()`. `.subscribe()` for dynamic, programmatic subscriptions that you manage explicitly. + +## Gotchas and Edge Cases + +**Infinite loops are possible** + +```python +# BAD: Modifying what you're watching +count = observable(0) + +@reactive(count) +def increment_forever(value): + count.set(value + 1) # DON'T DO THIS +``` + +Solution: Reactive functions should perform *side effects*, not modify the observables they're watching. Use computed observables for transformations. + +**Reactive functions don't track .get() or .value reads** + +```python +# BAD: Hidden dependency +other_count = observable(10) + +@reactive(count) +def show_sum(value): + print(f"Sum: {value + other_count.get()}") # Hidden dependency + +count.set(5) # Prints: "Sum: 15" +other_count.set(20) # Doesn't trigger show_sum - bug! +``` + +Solution: Make all dependencies explicit in the decorator. + +**Reactive functions receive values, not observables** + +```python +# BAD: Trying to modify the value parameter +@reactive(count) +def try_to_modify(value): + value.set(100) # ERROR: value is an int, not an observable +``` + +Solution: Access the observable directly if you need to modify it (though you usually shouldn't). + +**Store reactions receive snapshots** + +```python +@reactive(UserStore) +def save_user(store): + # store is a snapshot of UserStore at this moment + # store.name is the current value, not an observable + save_to_db(store.name) # Correct + + # This won't work: + store.name.subscribe(handler) # ERROR +``` + +**Order-dependent reactions are fragile** + +Express dependencies through computed observables instead of relying on execution order between multiple reactions. + +## Performance Considerations + +Reactive functions run synchronously on every change. For expensive operations, consider: + +**Reacting to derived state that filters changes:** + +```python +search_query = observable("") + +# Only changes when meaningful +filtered_results = search_query >> ( + lambda q: search_database(q) if len(q) >= 3 else [] +) + +@reactive(filtered_results) +def update_ui(results): + display_results(results) +``` + +**Using conditional observables to limit when reactions fire:** + +```python +should_update = (user_active & ~is_loading) >> (lambda x: x) + +@reactive(should_update) +def update_display(should): + if should: + expensive_render() +``` + +**Conditional logic inside reactions:** + +```python +@reactive(mouse_position) +def update_tooltip(position): + if should_show_tooltip(position): + expensive_tooltip_render(position) +``` + +## Summary + +The `@reactive` decorator transforms functions into automatic reactions that run whenever observables change: + +* **Declarative subscriptions** — No manual `.subscribe()` calls to manage +* **Runs on changes only** — No initial execution; waits for first change (pullback semantics) +* **Works with any observable** — Standalone, Store attributes, computed values, merged observables +* **Boolean operators for conditions** — Use `&`, `+`, `~` to create conditional reactions (like MobX's `when`) +* **Multiple observable support** — Derive combined observables first, then react +* **Store-level reactions** — React to any change in an entire Store +* **Lifecycle management** — Use `.unsubscribe()` to stop reactive behavior and restore normal function calls +* **Prevents manual calls** — Raises `fynx.reactive.ReactiveFunctionWasCalled` if called manually while subscribed +* **Side effects, not state changes** — Reactive functions should perform effects, not modify watched observables + +With `@reactive`, you declare *what should happen* when state changes. FynX ensures it happens automatically, in the right order, every time. This eliminates a whole category of synchronization bugs and makes your reactive systems self-maintaining. + +The rule of thumb here is that most of your code should be pure derivations using `>>`, `+`, `&`, and `~`. Reactions with `@reactive` appear only at the edges, where your application must interact with something external. This separation—the functional core, reactive shell pattern—is what makes reactive systems both powerful and maintainable. diff --git a/docs/generation/markdown/using-reactive.md b/docs/generation/markdown/using-reactive.md deleted file mode 100644 index 4e0e748..0000000 --- a/docs/generation/markdown/using-reactive.md +++ /dev/null @@ -1,548 +0,0 @@ -# @reactive: Automatic Reactions to Change - -Observables hold state, and Stores organize it. But how do you actually respond when that state changes? How do you keep UI, databases, and external systems in sync? - -Right now, if you want to respond to changes, you write this: - -```python -count = observable(0) - -def log_count(value): - print(f"Count: {value}") - -count.subscribe(log_count) -``` - -This works. But as your application grows, subscription management becomes tedious: - -```python -# Subscriptions scattered everywhere -count.subscribe(update_ui) -count.subscribe(save_to_database) -count.subscribe(notify_analytics) -name.subscribe(update_greeting) -email.subscribe(validate_email) -(first_name | last_name).subscribe(update_display_name) - -# Later... did you remember to unsubscribe? -count.unsubscribe(update_ui) -# Wait, which function was subscribed to which observable? -``` - -You're back to manual synchronization, just with a different syntax. The subscriptions themselves become state you have to manage. - -There's a better way. - -## Introducing @reactive - -The `@reactive` decorator turns functions into automatic reactions. Instead of manually subscribing, you declare *what observables matter* and FynX handles the rest: - -```python -from fynx import observable, reactive - -count = observable(0) - -@reactive(count) -def log_count(value): - print(f"Count: {value}") - -count.set(5) # Prints: "Count: 5" -count.set(10) # Prints: "Count: 10" -``` - -That's it. No manual subscription. No cleanup. Just a declaration: "this function reacts to this observable." - -The decorator does three things: - -1. **Subscribes automatically** — No need to call `.subscribe()` -2. **Runs immediately** — The function executes once when decorated, giving you the initial state -3. **Runs on every change** — Whenever the observable changes, the function runs with the new value - -This is the bridge from passive state management (observables and stores) to active behavior (side effects that respond to changes). - -## How It Works: The Execution Model - -Understanding when `@reactive` functions run is crucial: - -```python -count = observable(0) - -@reactive(count) -def log_count(value): - print(f"Count: {value}") - -# At this point, log_count has already run once with the initial value (0) -# Output so far: "Count: 0" - -count.set(5) # log_count runs again -# Output: "Count: 5" - -count.set(5) # Same value - does log_count run? -# Output: (no additional output - only runs when value actually changes) -``` - -The function runs: -- **Every time `.set()` is called with a different value** — Only when the value actually changes -- **Synchronously** — The function completes before `.set()` returns - -This synchronous execution is important. When you write `count.set(5)`, you know that all reactive functions have finished by the time the next line of code runs. This makes reactive code predictable and debuggable. - -## The Mental Model: Declarative Side Effects - -Traditional programming separates "doing" from "reacting": - -```python -# Traditional: Manual coordination -def update_count(new_value): - count = new_value - update_ui(count) # Remember to call this - save_to_database(count) # Remember to call this - log_change(count) # Remember to call this -``` - -Every time you modify state, you must remember all the dependent actions. Miss one and your application falls out of sync. - -With `@reactive`, you declare the relationships once: - -```python -# Reactive: Declare what should happen -@reactive(count) -def update_ui(value): - print(f"UI: {value}") - -@reactive(count) -def save_to_database(value): - print(f"Saving: {value}") - -@reactive(count) -def log_change(value): - print(f"Log: {value}") - -# Now just update state -count.set(42) -# All three functions run automatically -# UI: 42 -# Saving: 42 -# Log: 42 -``` - -You've moved from "remember to update everything" to "declare what should stay synchronized." The burden of coordination shifts from you to FynX. - -## Reacting to Multiple Observables - -Most real-world reactions depend on multiple pieces of state. `@reactive` accepts multiple observables: - -```python -first_name = observable("Alice") -last_name = observable("Smith") - -@reactive(first_name, last_name) -def greet(first, last): - print(f"Hello, {first} {last}!") - -# Runs immediately: "Hello, Alice Smith!" - -first_name.set("Bob") -# Runs again: "Hello, Bob Smith!" - -last_name.set("Jones") -# Runs again: "Hello, Bob Jones!" -``` - -When you pass multiple observables, the function receives their values as separate arguments, in the same order you listed them. Change any observable, and the function runs with all current values. - -This makes coordinating multiple state sources trivial: - -```python -class CartStore(Store): - items = observable([]) - tax_rate = observable(0.08) - -@reactive(CartStore.items, CartStore.tax_rate) -def update_total_display(items, rate): - subtotal = sum(item['price'] * item['quantity'] for item in items) - tax = subtotal * rate - total = subtotal + tax - print(f"Total: ${total:.2f}") - -# Runs when items change OR when tax_rate changes -``` - -You don't write separate subscriptions for each observable. You don't coordinate between them. You just declare: "this function needs these values, run it when any change." - -## Reacting to Entire Stores - -Sometimes you want to react to *any* change in a Store, regardless of which specific observable changed. Pass the Store class itself: - -```python -class UserStore(Store): - name = observable("Alice") - age = observable(30) - email = observable("alice@example.com") - -@reactive(UserStore) -def sync_to_server(store_snapshot): - print(f"Syncing: {store_snapshot.name}, {store_snapshot.email}") - -# Runs immediately with initial state - -# Runs when name changes: -UserStore.name = "Bob" - -# Runs when age changes: -UserStore.age = 31 - -# Runs when email changes: -UserStore.email = "bob@example.com" -``` - -The function receives a snapshot of the entire Store. This is perfect for operations that need to consider the complete state—saving to a database, logging changes, synchronizing with a server. - -Note the subtle difference: when reacting to individual observables, you get the *values* as arguments. When reacting to a Store, you get the *Store snapshot itself* as a single argument, and you access observables through it. - -## Reacting to Computed Observables - -Everything that's an observable—including computed ones—works with `@reactive`: - -```python -class CartStore(Store): - items = observable([]) - -# Computed observable -item_count = CartStore.items >> (lambda items: len(items)) - -@reactive(item_count) -def update_badge(count): - print(f"Cart badge: {count}") - -# Runs immediately with initial computed value (0) - -CartStore.items = [{'name': 'Widget', 'price': 10}] -# Computed value recalculates: 1 -# Reactive function runs: "Cart badge: 1" - -CartStore.items = CartStore.items + [{'name': 'Gadget', 'price': 15}] -# Computed value recalculates: 2 -# Reactive function runs: "Cart badge: 2" -``` - -You don't react to `CartStore.items` directly. You react to the *computed* value. This is powerful: it means you only care about changes in the *derived* state, not every modification to the underlying data. - -## Practical Example: Form Validation - -Here's where `@reactive` really shines—coordinating complex UI behavior: - -```python -class FormStore(Store): - email = observable("") - password = observable("") - confirm_password = observable("") - -# Computed validations -email_valid = FormStore.email >> ( - lambda e: '@' in e and '.' in e.split('@')[-1] -) - -password_valid = FormStore.password >> ( - lambda p: len(p) >= 8 -) - -passwords_match = (FormStore.password | FormStore.confirm_password) >> ( - lambda pwd, confirm: pwd == confirm and pwd != "" -) - -form_valid = (email_valid | password_valid | passwords_match) >> ( - lambda ev, pv, pm: ev and pv and pm -) - -# Reactive UI updates -@reactive(email_valid) -def update_email_indicator(is_valid): - status = "✓" if is_valid else "✗" - print(f"Email: {status}") - -@reactive(password_valid) -def update_password_indicator(is_valid): - status = "✓" if is_valid else "✗" - print(f"Password strength: {status}") - -@reactive(passwords_match) -def update_match_indicator(match): - status = "✓" if match else "✗" - print(f"Passwords match: {status}") - -@reactive(form_valid) -def update_submit_button(is_valid): - state = "enabled" if is_valid else "disabled" - print(f"Submit button: {state}") - -# Reactive functions run immediately with initial validation states -# Then update the form fields: -FormStore.email = "alice@example.com" -# Email: ✓ (email indicator runs) - -FormStore.password = "secure123" -# Password strength: ✓ (password indicator runs) -# Passwords match: ✗ (match indicator runs - passwords don't match yet) - -FormStore.confirm_password = "secure123" -# Passwords match: ✓ (match indicator runs) -# Submit button: enabled (form becomes valid) -``` - -Every UI element updates automatically in response to the relevant state changes. You never write "when email changes, check if it's valid and update the indicator." You just declare the relationship and FynX handles the orchestration. - -## When @reactive Runs: Understanding Execution Order - -When multiple observables change in quick succession, reactive functions run in a predictable order: - -```python -count = observable(0) - -@reactive(count) -def first_reaction(value): - print(f"First: {value}") - -@reactive(count) -def second_reaction(value): - print(f"Second: {value}") - -count.set(5) -# Output (order may vary): -# First: 5 -# Second: 5 -``` - -Reactive functions run in the order they were decorated. This ordering is deterministic but fragile—if reaction order matters to your application, you're probably doing something wrong. Each reaction should be independent, responding only to the observable values it receives. - -If you have reactions that depend on each other, consider using computed observables instead: - -```python -# Don't do this: reactions that depend on other reactions -shared_state = [] - -@reactive(count) -def reaction_one(value): - shared_state.append(value) - -@reactive(count) -def reaction_two(value): - # This assumes reaction_one has already run - print(f"Total accumulated: {sum(shared_state)}") - -# Do this instead: express dependencies through computed observables -accumulated = count >> (lambda c: sum(range(c + 1))) - -@reactive(accumulated) -def show_total(total): - print(f"Total: {total}") -``` - -## @reactive vs. Manual Subscriptions - -When should you use `@reactive` instead of calling `.subscribe()` directly? - -**Use `@reactive` when:** - -```python -# You want declarative, self-documenting code -@reactive(user_count) -def update_dashboard(count): - print(f"Users: {count}") - -# You need the function to run immediately with initial state -@reactive(theme) -def apply_theme(theme_name): - load_css(theme_name) # Runs right away - -# You're defining reactions at module level or class definition -class UIController: - @reactive(AppStore.mode) - def sync_mode(mode): - update_ui_mode(mode) -``` - -**Use `.subscribe()` when:** - -```python -# You need dynamic subscriptions that change at runtime -if user_wants_notifications: - count.subscribe(send_notification) - -# You need to unsubscribe conditionally -subscription_func = count.subscribe(handler) -if some_condition: - count.unsubscribe(subscription_func) - -# You're building a library that accepts observables -def create_widget(data_observable): - data_observable.subscribe(widget.update) -``` - -The rule of thumb: `@reactive` for static, declarative reactions that exist for the lifetime of your application. `.subscribe()` for dynamic, programmatic subscriptions that you manage explicitly. - -## Common Patterns - -**Pattern 1: Syncing to external systems** - -```python -@reactive(AppStore) -def save_state(store): - serialized = { - 'user': store.user, - 'settings': store.settings - } - save_to_local_storage('app_state', serialized) -``` - -**Pattern 2: Logging and debugging** - -```python -@reactive(UserStore.login_count) -def log_logins(count): - print(f"[DEBUG] Login count: {count}") - if count > 100: - print("[WARN] Unusual login activity detected") -``` - -**Pattern 3: Cross-store coordination** - -```python -@reactive(ThemeStore.mode) -def update_editor_theme(mode): - EditorStore.syntax_theme = "dark" if mode == "dark" else "light" -``` - -**Pattern 4: Analytics and tracking** - -```python -@reactive(CartStore.items) -def track_cart_changes(items): - analytics.track('cart_updated', { - 'item_count': len(items), - 'total_value': sum(item['price'] for item in items) - }) -``` - -## Gotchas and Edge Cases - -**1. Infinite loops are possible** - -```python -count = observable(0) - -@reactive(count) -def increment_forever(value): - count.set(value + 1) # DON'T DO THIS - -# This will hang your program -``` - -FynX doesn't prevent infinite loops. If your reactive function modifies an observable it's reacting to, you create a cycle. The solution: reactive functions should perform *side effects* (UI updates, logging, network calls), not modify the observables they're watching. - -**2. Reactive functions don't track .value reads** - -```python -other_count = observable(10) - -@reactive(count) -def show_sum(value): - print(f"Sum: {value + other_count.value}") - -count.set(5) # Prints: "Sum: 15" -other_count.set(20) # Doesn't trigger show_sum -``` - -The function only reacts to observables passed to `@reactive()`. Reading `other_count.value` inside the function doesn't create a dependency. If you want to react to both, pass both: - -```python -@reactive(count, other_count) -def show_sum(value, other): - print(f"Sum: {value + other}") -``` - -**3. Reactive functions receive values, not observables** - -```python -@reactive(count) -def try_to_modify(value): - value.set(100) # ERROR: value is an int, not an observable - -# If you need the observable, access it directly: -@reactive(count) -def correct_approach(value): - if value < 0: - count.set(0) # Access count directly, not through the argument -``` - -**4. Store reactions receive snapshots** - -```python -@reactive(UserStore) -def save_user(store): - # store is a snapshot of UserStore at this moment - # store.name is the current value, not an observable - save_to_db(store.name) # Correct - - # This won't give you an observable: - store.name.subscribe(handler) # ERROR -``` - -## Performance Considerations - -Reactive functions run synchronously on every change. For expensive operations, consider: - -**Debouncing through computed observables:** - -```python -search_query = observable("") - -# Computed observable that only changes when meaningful -filtered_results = search_query >> ( - lambda q: search_database(q) if len(q) >= 3 else [] -) - -@reactive(filtered_results) -def update_ui(results): - display_results(results) # Only runs when filter criteria met -``` - -**Conditional logic inside reactions:** - -```python -@reactive(mouse_position) -def update_tooltip(position): - if should_show_tooltip(position): # Guard clause - expensive_tooltip_render(position) -``` - -**Batching updates:** - -```python -pending_saves = [] - -@reactive(DocumentStore.content) -def queue_save(content): - pending_saves.append(content) - # Actual save happens elsewhere, periodically -``` - -## What's Next - -`@reactive` gives you automatic reactions to state changes. You can combine it with conditional observables to create event-driven reactions when specific conditions are met. This covers the full spectrum of reactive behaviors—from "keep this in sync" to "do this when that happens." - -With observables, stores, and `@reactive`, you have everything you need to build sophisticated reactive applications where state changes automatically propagate through your system, and important transitions trigger the right behaviors at the right times. - -## Summary - -The `@reactive` decorator transforms functions into automatic reactions that run whenever observables change: - -- **Declarative subscriptions** — No manual `.subscribe()` calls to manage -- **Runs immediately and on changes** — Get initial state and all updates -- **Works with any observable** — Standalone, Store attributes, computed values, merged observables, conditional observables -- **Multiple observable support** — React to several sources, receive values as arguments -- **Store-level reactions** — React to any change in an entire Store -- **Conditional reactions** — Use with conditional observables for event-driven behavior -- **Side effects, not state changes** — Reactive functions should perform effects, not modify watched observables - -With `@reactive`, you declare *what should happen* when state changes. FynX ensures it happens automatically, in the right order, every time. This eliminates a whole category of synchronization bugs and makes your reactive systems self-maintaining. diff --git a/docs/generation/mkdocs.yml b/docs/generation/mkdocs.yml index ddce39c..08f86ee 100644 --- a/docs/generation/mkdocs.yml +++ b/docs/generation/mkdocs.yml @@ -1,42 +1,52 @@ site_name: FynX site_description: "Python Reactive State Management Library" site_url: https://off-by-some.github.io/fynx/ -docs_dir: .. +docs_dir: markdown site_dir: ../../site # Ignore template files during MkDocs processing exclude_docs: | build/templates/** +extra: + version: + provider: mike + social: + - icon: fontawesome/brands/github + link: https://github.com/off-by-some/fynx + - icon: fontawesome/brands/python + link: https://pypi.org/project/fynx/ + nav: - - Home: index.md + - Introduction: + - Home: index.md + - Tutorial: + - Getting Started: tutorial/observables.md + - Derived Observables: tutorial/derived-observables.md + - Conditionals: tutorial/conditionals.md + - Stores: tutorial/stores.md + - Using @reactive: tutorial/using-reactive.md - API Reference: - - Overview: generation/markdown/api.md - - Observables: generation/markdown/observables.md - - Derived Observables: generation/markdown/derived-observables.md - - Conditionals: generation/markdown/conditionals.md - - Stores: generation/markdown/stores.md - - Using @reactive: generation/markdown/using-reactive.md - - Reference: - - Observable Types: - - Observable: generation/markdown/observable.md - - ComputedObservable: generation/markdown/computed-observable.md - - MergedObservable: generation/markdown/merged-observable.md - - ConditionalObservable: generation/markdown/conditional-observable.md - - Observable Descriptors: generation/markdown/observable-descriptors.md - - Observable Operators: generation/markdown/observable-operators.md - - Reactive Decorators: - - "@reactive": generation/markdown/reactive-decorator.md - - "Decorators": generation/markdown/decorators.md - - "Store & @observable": generation/markdown/store.md - - Mathematical Foundations: generation/markdown/mathematical-foundations.md + - Overview: reference/api.md + - Observable Types: + - Observable: reference/observable.md + - Observable Operators: reference/observable-operators.md + - ComputedObservable: reference/computed-observable.md + - MergedObservable: reference/merged-observable.md + - ConditionalObservable: reference/conditional-observable.md + - Observable Descriptors: reference/observable-descriptors.md + - Stores: reference/store.md + - Reactive Decorators: + - "@reactive": reference/reactive-decorator.md + - Mathematical Foundations: + - Mathematical Foundations: mathematical/mathematical-foundations.md - Roadmap: - - v1.0 Roadmap: specs/v1.0-roadmap.md + - v1.0 Roadmap: ../specs/v1.0-roadmap.md theme: name: material - favicon: images/icon_350x350.png - logo: images/icon_350x350.png + favicon: assets/images/icon_350x350.png + logo: assets/images/icon_350x350.png logo_text: FynX logo_text_align: center palette: @@ -108,20 +118,11 @@ markdown_extensions: - toc: permalink: true -extra: - version: - provider: mike - social: - - icon: fontawesome/brands/github - link: https://github.com/off-by-some/fynx - - icon: fontawesome/brands/python - link: https://pypi.org/project/fynx/ - extra_css: - - ../stylesheets/extra.css + - assets/stylesheets/extra.css extra_javascript: - - javascripts/mathjax.js + - assets/javascripts/mathjax.js - https://polyfill.io/v3/polyfill.min.js?features=es6 - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js diff --git a/docs/generation/scripts/generate_html.py b/docs/generation/scripts/generate_html.py old mode 100755 new mode 100644 index 3f8dc0e..16d3ac2 --- a/docs/generation/scripts/generate_html.py +++ b/docs/generation/scripts/generate_html.py @@ -27,11 +27,11 @@ def generate_html_docs() -> None: sys.exit(1) # Ensure docs directory and index.md exist - docs_dir = Path("docs") - index_md = docs_dir / "index.md" + markdown_dir = Path(__file__).parent.parent / "markdown" + index_md = markdown_dir / "index.md" if not index_md.exists(): print(f"❌ Index file not found at {index_md}") - print(" Please ensure docs/index.md exists") + print(" Please ensure docs/generation/markdown/index.md exists") sys.exit(1) # Build the HTML documentation @@ -43,11 +43,14 @@ def generate_html_docs() -> None: project_root = Path(__file__).parent.parent.parent.parent env["PYTHONPATH"] = str(project_root) + # Change to the generation directory where mkdocs.yml is located + generation_dir = Path(__file__).parent.parent + result = subprocess.run( - ["poetry", "run", "mkdocs", "build", "-f", str(mkdocs_config_path)], + ["poetry", "run", "mkdocs", "build"], capture_output=True, text=True, - cwd=".", + cwd=str(generation_dir), env=env, ) diff --git a/docs/generation/scripts/preview_html_docs.sh b/docs/generation/scripts/preview_html_docs.sh index 8bc9944..500b80a 100755 --- a/docs/generation/scripts/preview_html_docs.sh +++ b/docs/generation/scripts/preview_html_docs.sh @@ -27,5 +27,5 @@ echo " Press Ctrl+C to stop the server" echo "" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MKDOCS_CONFIG="$SCRIPT_DIR/../mkdocs.yml" -poetry run mkdocs serve -f "$MKDOCS_CONFIG" +cd "$SCRIPT_DIR/.." +poetry run mkdocs serve --dev-addr=127.0.0.1:8000 diff --git a/docs/specs/v1.0-roadmap.md b/docs/specs/v1.0-roadmap.md index 18bbb83..f74a993 100644 --- a/docs/specs/v1.0-roadmap.md +++ b/docs/specs/v1.0-roadmap.md @@ -582,7 +582,7 @@ subscription.unsubscribe() # Release callback reference **Acceptance Criteria**: ☐ `&` operator: `obs1 & obs2` returns observable evaluating AND during propagation -☐ `|` operator: `obs1 | obs2` returns observable evaluating OR during propagation (distinct from merge `@`) +☐ `+` operator: `obs1 + obs2` returns observable evaluating OR during propagation (distinct from merge `@`) ☐ `~` operator: `~obs` returns observable negating value during propagation ☐ Operators work element-wise on current values at propagation time ☐ For finite sources: produces `FiniteObservable` @@ -601,7 +601,7 @@ subscription.unsubscribe() # Release callback reference ```python ready = uploaded & valid & (~processing) -any_active = stream1 | stream2 # Boolean OR, not merge +any_active = stream1 + stream2 # Boolean OR, not merge # All evaluations happen in propagation worker (thread-safe) ``` @@ -695,15 +695,15 @@ def alert(is_alert): ## 7. Operator Summary Table --- -| Operator | Signature | Description | Example | -|----------|-----------|-------------|---------| -| `>>` | `Observable >> (T → U) → Observable` | Transform values (creates new observable) | `doubled = x >> (lambda v: v * 2)` | -| `\|` | `Observable \| Observable → MergedObservable` | Merge observables (deterministic order) | `combined = a \| b` | -| `<<` | `InfiniteObservable << T → None` | Thread-safe push to stream (enqueued) | `stream << value` | -| `.subscribe()` | `Observable.subscribe(T → None) → Subscription` | React to changes during propagation | `obs.subscribe(print)` | -| `&` | `Observable & Observable → Observable` | Boolean AND (evaluated during propagation) | `ready = a & b` | -| `~` | `~Observable → Observable` | Boolean NOT (evaluated during propagation) | `inactive = ~active` | -| `==`, `!=`, `<`, `<=`, `>`, `>=` | `Observable {op} T → Observable` | Comparison (evaluated during propagation) | `adult = age >= 18` | ++ Operator + Signature + Description + Example + ++----------+-----------+-------------+---------+ ++ `>>` + `Observable >> (T → U) → Observable` + Transform values (creates new observable) + `doubled = x >> (lambda v: v * 2)` + ++ `\+` + `Observable \+ Observable → MergedObservable` + Merge observables (deterministic order) + `combined = a \+ b` + ++ `<<` + `InfiniteObservable << T → None` + Thread-safe push to stream (enqueued) + `stream << value` + ++ `.subscribe()` + `Observable.subscribe(T → None) → Subscription` + React to changes during propagation + `obs.subscribe(print)` + ++ `&` + `Observable & Observable → Observable` + Boolean AND (evaluated during propagation) + `ready = a & b` + ++ `~` + `~Observable → Observable` + Boolean NOT (evaluated during propagation) + `inactive = ~active` + ++ `==`, `!=`, `<`, `<=`, `>`, `>=` + `Observable {op} T → Observable` + Comparison (evaluated during propagation) + `adult = age >= 18` + @@ -765,9 +765,9 @@ threading.Thread(target=lambda: CartStore.price_per_item = 15.0).start() # Cleanup subscription.unsubscribe() -# Boolean operators (| for OR, @ for merge) +# Boolean operators (+ for OR, @ for merge) preview_ready = uploaded_file & is_valid & (~is_processing) -any_error = validation_error | network_error # Boolean OR +any_error = validation_error + network_error # Boolean OR # Mixed type comparisons with concurrent emissions sensor_stream = InfiniteObservable() diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css deleted file mode 100644 index 2cfd0b4..0000000 --- a/docs/stylesheets/extra.css +++ /dev/null @@ -1,131 +0,0 @@ -/* Force the custom dark blue color #13162b throughout the Material theme */ - -:root { - --md-primary-fg-color: #13162b !important; - --md-primary-fg-color--light: #13162b !important; - --md-primary-fg-color--dark: #13162b !important; - --md-accent-fg-color: #13162b !important; - --md-accent-fg-color--transparent: rgba(19, 22, 43, 0.1) !important; -} - -/* Header - most visible element */ -.md-header, -.md-header[data-md-component="header"] { - background-color: #13162b !important; - background: #13162b !important; -} - -/* Navigation items */ -.md-nav__link, -.md-nav__link--active, -.md-nav__link:focus, -.md-nav__link:hover, -.md-nav__link:active { - color: #13162b !important; -} - -/* Top navigation bar */ -.md-header__inner { - background-color: #13162b !important; -} - -/* Main navigation toggle button */ -.md-header__button.md-icon { - color: white !important; -} - -/* Breadcrumbs */ -.md-nav--primary .md-nav__link { - color: white !important; -} - -.md-nav--primary .md-nav__link:hover, -.md-nav--primary .md-nav__link:focus { - color: #cccccc !important; -} - -/* Content links */ -.md-content a, -.md-content a:link, -.md-content a:visited { - color: #13162b !important; -} - -.md-content a:hover, -.md-content a:focus { - color: #13162b !important; -} - -/* Buttons */ -.md-button, -.md-button--primary, -button.md-button { - background-color: #13162b !important; - color: white !important; - border-color: #13162b !important; -} - -.md-button:hover, -.md-button:focus, -.md-button--primary:hover, -.md-button--primary:focus { - background-color: #0a0c1a !important; - color: white !important; - border-color: #0a0c1a !important; -} - -/* Search button */ -.md-search__input, -.md-search__form input { - border-color: #13162b !important; -} - -.md-search__input:focus, -.md-search__form input:focus { - border-color: #13162b !important; - box-shadow: 0 0 0 0.2rem rgba(19, 22, 43, 0.25) !important; -} - -/* Sidebar */ -.md-sidebar { - background-color: #f8f9fa !important; -} - -/* Dark mode overrides */ -[data-md-color-scheme="slate"] { - --md-primary-fg-color: #13162b !important; - --md-primary-fg-color--light: #13162b !important; - --md-primary-fg-color--dark: #13162b !important; - --md-accent-fg-color: #13162b !important; -} - -[data-md-color-scheme="slate"] .md-header, -[data-md-color-scheme="slate"] .md-header[data-md-component="header"], -[data-md-color-scheme="slate"] .md-header__inner { - background-color: #13162b !important; - background: #13162b !important; -} - -[data-md-color-scheme="slate"] .md-nav__link, -[data-md-color-scheme="slate"] .md-nav__link--active, -[data-md-color-scheme="slate"] .md-nav__link:focus, -[data-md-color-scheme="slate"] .md-nav__link:hover { - color: #13162b !important; -} - -[data-md-color-scheme="slate"] .md-sidebar { - background-color: #1e1e1e !important; -} - -[data-md-color-scheme="slate"] .md-content a, -[data-md-color-scheme="slate"] .md-content a:link, -[data-md-color-scheme="slate"] .md-content a:visited { - color: #13162b !important; -} - -[data-md-color-scheme="slate"] .md-button, -[data-md-color-scheme="slate"] .md-button--primary { - background-color: #13162b !important; - color: white !important; - border-color: #13162b !important; -} diff --git a/examples/advanced_user_profile.py b/examples/advanced_user_profile.py index 5aaf002..edd09ce 100644 --- a/examples/advanced_user_profile.py +++ b/examples/advanced_user_profile.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import Optional -from fynx import Store, observable, reactive, watch +from fynx import Store, observable, reactive from fynx.observable.computed import ComputedObservable @@ -90,7 +90,7 @@ def on_profile_change(profile_snapshot): print("\nCreating computed properties...") # Build complex computed properties from simpler transformations -full_name = (UserProfile.first_name | UserProfile.last_name).then( +full_name = (UserProfile.first_name + UserProfile.last_name).then( lambda f, l: f"{f} {l}".strip() ) @@ -105,7 +105,7 @@ def on_profile_change(profile_snapshot): # Account status combining multiple factors account_status = ( - UserProfile.is_active | UserProfile.is_verified | UserProfile.subscription_tier + UserProfile.is_active + UserProfile.is_verified + UserProfile.subscription_tier ).then( lambda active, verified, tier: ( "premium_active" @@ -117,13 +117,13 @@ def on_profile_change(profile_snapshot): # Profile completeness score (0-100) profile_completeness = ( UserProfile.first_name - | UserProfile.last_name - | UserProfile.email - | UserProfile.phone + + UserProfile.last_name + + UserProfile.email + + UserProfile.phone ).then(lambda fn, ln, em, ph: sum([bool(fn), bool(ln), bool(em), bool(ph)]) / 4 * 100) # Display name with fallback logic -display_name = (full_name | UserProfile.email).then( +display_name = (full_name + UserProfile.email).then( lambda name, email: ( name if name.strip() else email.split("@")[0] if email else "Anonymous" ) @@ -221,7 +221,7 @@ def on_name_change(first, last): print(f"🏷️ Name updated: {first} {last}") -name_observables = UserProfile.first_name | UserProfile.last_name +name_observables = UserProfile.first_name + UserProfile.last_name name_observables.subscribe(on_name_change) @@ -232,7 +232,7 @@ def on_name_change(first, last): @reactive(is_adult & is_premium) -def on_eligible_user(): +def on_eligible_user(condition_value): print("🎯 User is now eligible for premium features!") @@ -244,7 +244,7 @@ def on_eligible_user(): @reactive(many_logins & notifications_disabled) -def on_suspicious_activity(): +def on_suspicious_activity(condition_value): print("🚨 Suspicious activity detected - many logins with notifications disabled") @@ -288,7 +288,7 @@ def simulate_login(): @reactive(first_login) -def welcome_new_user(): +def welcome_new_user(condition_value): print("🎊 Welcome! This is your first login!") @@ -297,7 +297,7 @@ def welcome_new_user(): @reactive(tenth_login) -def reward_milestone(): +def reward_milestone(condition_value): print("🏆 Milestone reached: 10 logins! Here's a virtual badge!") @@ -360,9 +360,9 @@ def suggest_features(streak): # User engagement score based on multiple factors engagement_factors = ( profile_completeness - | UserProfile.login_count - | UserProfile.is_verified - | age_category + + UserProfile.login_count + + UserProfile.is_verified + + age_category ).then( lambda completeness, logins, verified, age_cat: ( (completeness / 100 * 0.4) # 40% weight on profile completeness @@ -374,7 +374,7 @@ def suggest_features(streak): # Auto-upgrade eligibility (complex business logic) can_auto_upgrade = ( - engagement_factors | UserProfile.subscription_tier | UserProfile.is_active + engagement_factors + UserProfile.subscription_tier + UserProfile.is_active ).then(lambda engagement, tier, active: active and tier == "free" and engagement > 0.7) @@ -387,7 +387,7 @@ def check_auto_upgrade(eligible): # Dynamic feature access based on multiple conditions age_eligible = UserProfile.age.then(lambda age: age >= 13) -advanced_features_access = (can_access_premium | profile_is_valid | age_eligible).then( +advanced_features_access = (can_access_premium + profile_is_valid + age_eligible).then( lambda premium, valid, age_ok: premium and valid and age_ok ) diff --git a/examples/basics.py b/examples/basics.py index 6871ebe..d2f3a90 100644 --- a/examples/basics.py +++ b/examples/basics.py @@ -37,8 +37,8 @@ def log_name_and_age_change(name, age): print(f"Name: {name}, Age: {age}") -# Define a merged observable with the | operator. -current_name_and_age = current_name | current_age +# Define a merged observable with the + operator. +current_name_and_age = current_name + current_age # Subscribe to the merged observable. current_name_and_age.subscribe(log_name_and_age_change) @@ -139,7 +139,7 @@ def on_age_change(age, name): print() print("=" * 100) -print("Using with (ExampleStore.name | ExampleStore.age) as react") +print("Using with (ExampleStore.name + ExampleStore.age) as react") print("-" * 100) print() @@ -149,7 +149,7 @@ def on_name_age_change(name, age): # Subscribe to multiple observables at once -with ExampleStore.name | ExampleStore.age as react: +with ExampleStore.name + ExampleStore.age as react: react(on_name_age_change) ExampleStore.name = "Bob" @@ -173,7 +173,7 @@ def on_name_age_change(name, age): @reactive(is_online & has_messages) -def notify_user(): +def notify_user(condition_value): print(f"📬 Notifying user: {message_count.value} new messages while online!") @@ -205,7 +205,7 @@ def notify_user(): # Step 2: Combine related values into a single reactive unit # This creates a reactive pair: (height, weight) -bmi_data = height | weight +bmi_data = height + weight # Step 3: Transform the combined data using the >> operator diff --git a/examples/cart_checkout.py b/examples/cart_checkout.py index e33c616..aa835a0 100644 --- a/examples/cart_checkout.py +++ b/examples/cart_checkout.py @@ -12,7 +12,7 @@ def update_ui(total: float): # Link item_count and price_per_item to auto-calculate total_price -combined_observables = CartStore.item_count | CartStore.price_per_item +combined_observables = CartStore.item_count + CartStore.price_per_item # The >> operator takes any observable and passes the value(s) to the right. total_price = combined_observables >> (lambda count, price: count * price) diff --git a/examples/streamlit/todo_store.py b/examples/streamlit/todo_store.py index 558c1ed..50a2fff 100644 --- a/examples/streamlit/todo_store.py +++ b/examples/streamlit/todo_store.py @@ -124,7 +124,7 @@ class TodoStore(StreamlitStore): completed_count = completed_todos >> (lambda completed_list: len(completed_list)) # Dynamic filtering based on current filter mode - filtered_todos = (todos | filter_mode) >> ( + filtered_todos = (todos + filter_mode) >> ( lambda todos_list, current_filter: { FILTER_MODE_ACTIVE: [todo for todo in todos_list if not todo.completed], FILTER_MODE_COMPLETED: [todo for todo in todos_list if todo.completed], diff --git a/examples/using_reactive_conditionals.py b/examples/using_reactive_conditionals.py index f8ae595..08a9294 100644 --- a/examples/using_reactive_conditionals.py +++ b/examples/using_reactive_conditionals.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """ -Examples demonstrating the @watch decorator for conditional reactive programming. +Examples demonstrating the @reactive decorator for conditional reactive programming. -This script showcases various patterns for using @watch to create event-driven +This script showcases various patterns for using @reactive to create event-driven reactive functions that trigger when specific conditions become true. -Run this script to see @watch in action: +Run this script to see @reactive in action: - python examples/using_watch.py + python examples/using_reactive_conditionals.py Each example demonstrates a different aspect of conditional reactive programming. """ @@ -18,20 +18,24 @@ def basic_watch_example(): - """Demonstrate basic @watch functionality with an age threshold. + """Demonstrate basic @reactive functionality with an age threshold. - This example shows how @watch triggers only when a condition transitions - from false to true, not on every change. + This example shows how @reactive triggers only when a condition transitions + from False to True, not on every change. """ - print("=== Basic @watch Example ===") + print("=== Basic @reactive Example ===") print("Watching for when a user becomes an adult (age >= 18)") print() age = observable(16) - @watch(lambda: age.value >= 18) - def on_becomes_adult(): - print(f"User became an adult at age {age.value}") + # Create a conditional observable for the age threshold + is_adult = age >> (lambda a: a >= 18) + + @reactive(is_adult) + def on_becomes_adult_basic(is_adult_value): + if is_adult_value: + print(f"User became an adult at age {age.value}") # Demonstrate the transition behavior print(f"Initial age: {age.value}") @@ -43,13 +47,14 @@ def on_becomes_adult(): age.set(19) print(f"Age set to: {age.value} (condition stays true, no additional trigger)") + print() def multiple_conditions_and_example(): """Demonstrate multiple conditions with AND logic. - When multiple condition functions are passed to @watch, all must be true + When multiple condition functions are passed to @reactive, all must be true for the decorated function to trigger. This implements logical AND behavior. """ print("=== Multiple Conditions AND Logic ===") @@ -59,9 +64,13 @@ def multiple_conditions_and_example(): has_items = observable(False) is_logged_in = observable(False) - @watch(lambda: has_items.value, lambda: is_logged_in.value) - def on_ready_to_checkout(): - print("Ready to checkout - all conditions met!") + # Create conditional observables for AND logic + ready_to_checkout = (has_items + is_logged_in) >> (lambda h, l: h and l) + + @reactive(ready_to_checkout) + def on_ready_to_checkout_and(ready_value): + if ready_value: + print("Ready to checkout - all conditions met!") # Demonstrate AND logic print("Initial state: has_items=False, is_logged_in=False") @@ -72,6 +81,48 @@ def on_ready_to_checkout(): is_logged_in.set(True) print("Set is_logged_in=True (both conditions now true, triggers!)") + # Clean up + on_ready_to_checkout_and.unsubscribe() + print() + + +def conditional_observable_or_example(): + """Demonstrate ConditionalObservable with the | operator. + + The | operator creates ConditionalObservable objects that combine multiple + boolean observables with OR logic, emitting when ANY condition is true. + """ + print("=== ConditionalObservable with | Operator ===") + print("Using | operator for OR conditions: is_error | is_warning | is_critical") + print() + + is_error = observable(False) + is_warning = observable(True) # Start with True to avoid ConditionalNeverMet + is_critical = observable(False) + + # Create OR condition using | operator + needs_attention = is_error | is_warning | is_critical + + @reactive(needs_attention) + def on_attention_needed_or(needs_attention_state): + if needs_attention_state: + print("⚠️ System needs attention! (OR condition met)") + + # Demonstrate the | operator behavior + print("Initial state: is_warning=True, others False") + print("OR condition: False | True | False = True") + + is_error.set(True) + print("Set is_error=True (2/3 conditions met, still triggers)") + + is_warning.set(False) + print("Set is_warning=False (1/3 conditions met, still triggers)") + + is_error.set(False) + print("Set is_error=False (0/3 conditions met, no longer triggers)") + + # Clean up + on_attention_needed_or.unsubscribe() print() @@ -92,8 +143,8 @@ def conditional_observable_and_example(): is_logged_in = observable(False) payment_valid = observable(False) - @watch(has_items & is_logged_in & payment_valid) - def on_ready_to_checkout(): + @reactive(has_items & is_logged_in & payment_valid) + def on_ready_to_checkout_and_op(condition_value): print("Ready to checkout - all conditions met using & operator!") # Demonstrate the & operator behavior @@ -108,13 +159,15 @@ def on_ready_to_checkout(): payment_valid.set(True) print("Set payment_valid=True (3/3 conditions met, triggers!)") + # Clean up + on_ready_to_checkout_and_op.unsubscribe() print() def form_submission_flow_example(): """Demonstrate a multi-step form submission workflow. - This example shows how @watch can be used to manage complex state transitions + This example shows how @reactive can be used to manage complex state transitions in a form submission process, from validation to completion. """ print("=== Form Submission Flow ===") @@ -134,21 +187,24 @@ class FormStore(Store): password_valid = FormStore.password >> (lambda p: len(p) >= 8) # Combine all validations - all_valid = (email_valid | password_valid | FormStore.terms_accepted) >> ( + all_valid = (email_valid + password_valid + FormStore.terms_accepted) >> ( lambda e, p, t: e and p and t ) - @watch(lambda: all_valid.value) - def on_form_valid(): - print("Form validation passed - submit button enabled") + @reactive(all_valid) + def on_form_valid_submit(is_valid): + if is_valid: + print("Form validation passed - submit button enabled") - @watch(lambda: FormStore.is_submitting.value) - def on_submit_start(): - print("Form submission started") + @reactive(FormStore.is_submitting) + def on_submit_start_submit(is_submitting): + if is_submitting: + print("Form submission started") - @watch(lambda: FormStore.submission_complete.value) - def on_submit_complete(): - print("Form submission completed successfully") + @reactive(FormStore.submission_complete) + def on_submit_complete_submit(is_complete): + if is_complete: + print("Form submission completed successfully") # Simulate user filling out the form print("User fills out form:") @@ -171,13 +227,17 @@ def on_submit_complete(): FormStore.submission_complete = True print("Submission completed") + # Clean up + on_form_valid_submit.unsubscribe() + on_submit_start_submit.unsubscribe() + on_submit_complete_submit.unsubscribe() print() def complex_conditions_example(): """Demonstrate complex boolean conditions with logical operators. - This example shows how @watch can handle complex conditions involving + This example shows how @reactive can handle complex conditions involving AND, OR, and other logical operators within lambda functions. """ print("=== Complex Boolean Conditions ===") @@ -187,11 +247,15 @@ def complex_conditions_example(): temperature = observable(20) humidity = observable(50) - @watch(lambda: (temperature.value > 25 and humidity.value < 60)) - def on_comfortable(): - print( - f"Climate became comfortable (temp: {temperature.value}, humidity: {humidity.value})" - ) + # Create conditional observable for complex conditions + is_comfortable = (temperature + humidity) >> (lambda t, h: t > 25 and h < 60) + + @reactive(is_comfortable) + def on_comfortable_complex(comfortable_value): + if comfortable_value: + print( + f"Climate became comfortable (temp: {temperature.value}, humidity: {humidity.value})" + ) # Demonstrate the complex condition logic print("Initial state: temperature=20, humidity=50") @@ -215,13 +279,15 @@ def on_comfortable(): print("Set temperature=30") print("Condition: 30 > 25 AND 50 < 60 = True AND True = True (triggers again)") + # Clean up + on_comfortable_complex.unsubscribe() print() def one_time_vs_repeating_events_example(): """Demonstrate one-time events versus repeating milestone events. - This example shows how @watch can handle both events that occur only once + This example shows how @reactive can handle both events that occur only once and events that repeat at regular intervals. """ print("=== One-time vs Repeating Events ===") @@ -232,17 +298,24 @@ def one_time_vs_repeating_events_example(): login_count = observable(0) - @watch(lambda: login_count.value == 1) - def on_first_login(): - print(f"First login milestone reached at login #{login_count.value}") + # Create conditional observables for milestone tracking + is_first_login = login_count >> (lambda count: count == 1) + + @reactive(is_first_login) + def on_first_login_milestone(is_first_value): + if is_first_value: + print(f"First login milestone reached at login #{login_count.value}") - last_milestone = 0 + last_milestone = observable(0) + is_milestone_login = (login_count + last_milestone) >> ( + lambda count, last: count >= last + 10 + ) - @watch(lambda: login_count.value >= last_milestone + 10) - def on_login_milestone(): - nonlocal last_milestone - last_milestone = login_count.value - print(f"Login milestone reached: {login_count.value} total logins") + @reactive(is_milestone_login) + def on_login_milestone_repeat(is_milestone_value): + if is_milestone_value: + last_milestone.set(login_count.value) + print(f"Login milestone reached: {login_count.value} total logins") # Simulate user logins print("Simulating user logins:") @@ -253,11 +326,14 @@ def on_login_milestone(): f" Login #{i}: {'(first login triggered)' if i == 1 else '(milestone triggered)' if i in [10, 20] else ''}" ) + # Clean up + on_first_login_milestone.unsubscribe() + on_login_milestone_repeat.unsubscribe() print() def computed_observables_combination_example(): - """Demonstrate combining @watch with computed observables. + """Demonstrate combining @reactive with computed observables. This example shows how to use computed observables to derive complex state from simple observables, then watch for transitions on the computed values. @@ -277,13 +353,14 @@ class ShoppingCartStore(Store): has_payment = ShoppingCartStore.payment_method >> (lambda pm: pm is not None) # Combine all conditions into a single computed observable - can_checkout = (has_items | has_shipping | has_payment) >> ( + can_checkout = (has_items + has_shipping + has_payment) >> ( lambda items, shipping, payment: items and shipping and payment ) - @watch(lambda: can_checkout.value) - def on_checkout_ready(): - print("Checkout became available - all requirements met") + @reactive(can_checkout) + def on_checkout_ready_computed(can_checkout_value): + if can_checkout_value: + print("Checkout became available - all requirements met") # Demonstrate the checkout flow print("Building checkout state:") @@ -296,6 +373,8 @@ def on_checkout_ready(): ShoppingCartStore.payment_method = "credit_card" print("Added payment method (all conditions now met, triggers checkout ready)") + # Clean up + on_checkout_ready_computed.unsubscribe() print() @@ -316,8 +395,8 @@ def conditional_observable_with_computed_example(): # Computed observable: cart meets minimum total has_minimum_total = cart_total >> (lambda total: total >= 50) - @watch(user_logged_in & data_loaded & has_minimum_total) - def on_ready_with_minimum(): + @reactive(user_logged_in & data_loaded & has_minimum_total) + def on_ready_with_minimum_computed(condition_value): print(f"Ready for premium checkout (total: ${cart_total.value})") # Demonstrate the combined logic @@ -334,13 +413,15 @@ def on_ready_with_minimum(): cart_total.set(60) print("Cart total set to $60 (meets minimum, all conditions now true, triggers)") + # Clean up + on_ready_with_minimum_computed.unsubscribe() print() def user_engagement_system_example(): """Demonstrate a comprehensive user engagement tracking system. - This example shows how @watch can be used to create sophisticated engagement + This example shows how @reactive can be used to create sophisticated engagement tracking with multiple threshold levels and different types of events. """ print("=== User Engagement System ===") @@ -357,27 +438,36 @@ class UserActivityStore(Store): # Computed engagement score: min(100, (views * 5) + (actions * 10) + (time / 6)) engagement_score = ( UserActivityStore.page_views - | UserActivityStore.actions_taken - | UserActivityStore.time_on_site + + UserActivityStore.actions_taken + + UserActivityStore.time_on_site ) >> ( lambda views, actions, time: min(100, (views * 5) + (actions * 10) + (time / 6)) ) - @watch(lambda: engagement_score.value >= 25) - def on_low_engagement(): - print(f"Low engagement reached (score: {engagement_score.value:.1f})") + # Create conditional observables for engagement levels + low_engagement = engagement_score >> (lambda score: score >= 25) + medium_engagement = engagement_score >> (lambda score: score >= 50) + high_engagement = engagement_score >> (lambda score: score >= 75) - @watch(lambda: engagement_score.value >= 50) - def on_medium_engagement(): - print(f"Medium engagement reached (score: {engagement_score.value:.1f})") + @reactive(low_engagement) + def on_low_engagement_user(has_low): + if has_low: + print(f"Low engagement reached (score: {engagement_score.value:.1f})") - @watch(lambda: engagement_score.value >= 75) - def on_high_engagement(): - print(f"High engagement reached (score: {engagement_score.value:.1f})") + @reactive(medium_engagement) + def on_medium_engagement_user(has_medium): + if has_medium: + print(f"Medium engagement reached (score: {engagement_score.value:.1f})") - @watch(lambda: UserActivityStore.has_account.value) - def on_account_created(): - print("Account created - user registration completed") + @reactive(high_engagement) + def on_high_engagement_user(has_high): + if has_high: + print(f"High engagement reached (score: {engagement_score.value:.1f})") + + @reactive(UserActivityStore.has_account) + def on_account_created_user(has_account): + if has_account: + print("Account created - user registration completed") # Simulate user engagement progression print("User engagement progression:") @@ -407,17 +497,22 @@ def on_account_created(): UserActivityStore.time_on_site = 180 print("User spends even more time (score: 80, triggers high engagement)") + # Clean up + on_low_engagement_user.unsubscribe() + on_medium_engagement_user.unsubscribe() + on_high_engagement_user.unsubscribe() + on_account_created_user.unsubscribe() print() def order_processing_pipeline_example(): - """Demonstrate an order processing pipeline using @watch. + """Demonstrate an order processing pipeline using @reactive. - This example shows how @watch can orchestrate complex multi-step workflows + This example shows how @reactive can orchestrate complex multi-step workflows where each stage depends on the completion of previous stages. """ print("=== Order Processing Pipeline ===") - print("Multi-step workflow orchestration with @watch") + print("Multi-step workflow orchestration with @reactive") print() class OrderStore(Store): @@ -428,37 +523,46 @@ class OrderStore(Store): shipped = observable(False) delivered = observable(False) - # Pipeline stages - each @watch represents a transition to the next stage - @watch(lambda: len(OrderStore.items.value) > 0) - def on_order_created(): - print("Order created - items added to cart") - - @watch( - lambda: OrderStore.payment_verified.value, - lambda: len(OrderStore.items.value) > 0, - ) - def on_payment_verified(): - print("Payment verified - proceeding to inventory check") - # Simulate automatic inventory reservation - OrderStore.inventory_reserved = True - - @watch(lambda: OrderStore.inventory_reserved.value) - def on_inventory_reserved(): - print("Inventory reserved - generating shipping label") - # Simulate automatic label creation - OrderStore.shipping_label_created = True + # Pipeline stages - each @reactive represents a transition to the next stage + has_items = OrderStore.items >> (lambda items: len(items) > 0) - @watch(lambda: OrderStore.shipping_label_created.value) - def on_label_created(): - print("Shipping label created - order ready for shipment") + @reactive(has_items) + def on_order_created_pipeline(has_items_value): + if has_items_value: + print("Order created - items added to cart") - @watch(lambda: OrderStore.shipped.value) - def on_shipped(): - print("Order shipped - tracking number generated") + payment_and_items = (OrderStore.payment_verified + OrderStore.items) >> ( + lambda payment, items: payment and len(items) > 0 + ) - @watch(lambda: OrderStore.delivered.value) - def on_delivered(): - print("Order delivered - customer notified") + @reactive(payment_and_items) + def on_payment_verified_pipeline(payment_and_items_value): + if payment_and_items_value: + print("Payment verified - proceeding to inventory check") + # Simulate automatic inventory reservation + OrderStore.inventory_reserved = True + + @reactive(OrderStore.inventory_reserved) + def on_inventory_reserved_pipeline(inventory_reserved): + if inventory_reserved: + print("Inventory reserved - generating shipping label") + # Simulate automatic label creation + OrderStore.shipping_label_created = True + + @reactive(OrderStore.shipping_label_created) + def on_label_created_pipeline(shipping_label_created): + if shipping_label_created: + print("Shipping label created - order ready for shipment") + + @reactive(OrderStore.shipped) + def on_shipped_pipeline(shipped): + if shipped: + print("Order shipped - tracking number generated") + + @reactive(OrderStore.delivered) + def on_delivered_pipeline(delivered): + if delivered: + print("Order delivered - customer notified") # Execute the order processing pipeline print("Processing customer order:") @@ -474,7 +578,7 @@ def on_delivered(): OrderStore.payment_verified = True print("Payment processed") - # Subsequent stages are triggered automatically by the @watch decorators + # Subsequent stages are triggered automatically by the @reactive decorators # Each stage advances the order through the pipeline # Stage 5: Warehouse ships the order @@ -485,17 +589,27 @@ def on_delivered(): OrderStore.delivered = True print("Order delivered to customer") + # Clean up + on_order_created_pipeline.unsubscribe() + on_payment_verified_pipeline.unsubscribe() + on_inventory_reserved_pipeline.unsubscribe() + on_label_created_pipeline.unsubscribe() + on_shipped_pipeline.unsubscribe() + on_delivered_pipeline.unsubscribe() print() def main(): - """Run all examples demonstrating @watch functionality.""" - print("Running @watch examples demonstrating conditional reactive programming...") + """Run all examples demonstrating @reactive functionality.""" + print( + "Running @reactive examples demonstrating conditional reactive programming..." + ) print("=" * 70) print() basic_watch_example() multiple_conditions_and_example() + conditional_observable_or_example() conditional_observable_and_example() form_submission_flow_example() complex_conditions_example() @@ -508,7 +622,7 @@ def main(): print("=" * 70) print("All examples completed successfully.") print( - "These examples demonstrate the various ways to use @watch for conditional reactive programming." + "These examples demonstrate the various ways to use @reactive for conditional reactive programming." ) return 0 diff --git a/fynx/__init__.py b/fynx/__init__.py index e1dba5d..19e66cc 100644 --- a/fynx/__init__.py +++ b/fynx/__init__.py @@ -44,7 +44,7 @@ class UserStore(Store): age = observable(30) # Computed property using the >> operator - greeting = (name | age) >> (lambda n, a: f"Hello, {n}! You are {a} years old.") + greeting = (name + age) >> (lambda n, a: f"Hello, {n}! You are {a} years old.") # React to changes @reactive(UserStore.name, UserStore.age) diff --git a/fynx/observable/__init__.py b/fynx/observable/__init__.py index c21b104..ad1183f 100644 --- a/fynx/observable/__init__.py +++ b/fynx/observable/__init__.py @@ -28,7 +28,7 @@ automatic dependency tracking and coordinating updates when dependencies change. **MergedObservable**: Combines multiple observables into a single reactive unit using -the `|` operator. Useful for coordinating related values and passing them as a group +the `+` operator. Useful for coordinating related values and passing them as a group to computed functions. **ConditionalObservable**: Filters reactive streams based on boolean conditions using @@ -48,9 +48,9 @@ FynX provides intuitive operators for composing reactive behaviors: -**Merge (`|`)**: Combines observables into tuples for coordinated updates: +**Merge (`+`)**: Combines observables into tuples for coordinated updates: ```python -point = x | y # Creates (x.value, y.value) that updates when either changes +point = x + y # Creates (x.value, y.value) that updates when either changes ``` **Transform (`>>`)**: Applies functions to create computed values: @@ -115,7 +115,7 @@ def on_change(): height = Observable("height", 20) # Merge them into a single reactive unit -dimensions = width | height +dimensions = width + height print(dimensions.value) # (10, 20) # Changes to either update the merged observable diff --git a/fynx/observable/computed.py b/fynx/observable/computed.py index 69ac3cd..4861917 100644 --- a/fynx/observable/computed.py +++ b/fynx/observable/computed.py @@ -33,22 +33,22 @@ ----------------------------- While you can create ComputedObservable instances directly, it's more common to use -the `computed()` function which handles the reactive setup automatically: +the `>>` operator or `.then()` method which handles the reactive setup automatically: ```python -from fynx import observable, computed +from fynx import observable # Base observables price = observable(10.0) quantity = observable(5) -# Computed observable using the computed() function -total = computed(lambda p, q: p * q, price | quantity) +# Computed observable using the >> operator (modern approach) +total = (price + quantity) >> (lambda p, q: p * q) print(total.value) # 50.0 -# Direct creation (less common) -from fynx.observable.computed import ComputedObservable -manual = ComputedObservable("manual", 42) +# Alternative using .then() method +total_alt = (price + quantity).then(lambda p, q: p * q) +print(total_alt.value) # 50.0 ``` Read-Only Protection @@ -57,7 +57,7 @@ Computed observables prevent accidental direct modification: ```python -total = computed(lambda p, q: p * q, price | quantity) +total = (price + quantity) >> (lambda p, q: p * q) # This works - updates automatically price.set(15) @@ -73,7 +73,7 @@ The framework can update computed values through the internal `_set_computed_value()` method: ```python -# This is used internally by the computed() function +# This is used internally by the >> operator and .then() method computed_obs._set_computed_value(new_value) # Allowed computed_obs.set(new_value) # Not allowed ``` @@ -93,33 +93,27 @@ ```python width = observable(10) height = observable(20) -area = computed(lambda w, h: w * h, width | height) -perimeter = computed(lambda w, h: 2 * (w + h), width | height) +area = (width + height) >> (lambda w, h: w * h) +perimeter = (width + height) >> (lambda w, h: 2 * (w + h)) ``` **String Formatting**: ```python first_name = observable("John") last_name = observable("Doe") -full_name = computed( - lambda f, l: f"{f} {l}", - first_name | last_name -) +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") ``` **Validation States**: ```python email = observable("") -is_valid_email = computed( - lambda e: "@" in e and len(e) > 5, - email -) +is_valid_email = email >> (lambda e: "@" in e and len(e) > 5) ``` **Conditional Computations**: ```python count = observable(0) -is_even = computed(lambda c: c % 2 == 0, count) +is_even = count >> (lambda c: c % 2 == 0) ``` Limitations @@ -141,7 +135,7 @@ See Also -------- -- `fynx.computed`: The computed() function for creating computed observables +- `fynx.observable`: The >> operator and .then() method for creating computed observables - `fynx.observable`: Core observable classes - `fynx.store`: For organizing observables in reactive containers """ @@ -194,7 +188,7 @@ def _set_computed_value(self, value: Optional[T]) -> None: """ Internal method for updating computed observable values. - This method is called internally by the computed() function when dependencies + This method is called internally by the >> operator and .then() method when dependencies change. It bypasses the read-only protection enforced by the public set() method to allow legitimate framework-driven updates of computed values. @@ -217,14 +211,14 @@ def set(self, value: Optional[T]) -> None: directly would break the reactive relationship and defeat the purpose of computed values. - To create a computed observable, use the `computed()` function instead: + To create a computed observable, use the >> operator or .then() method instead: ```python - from fynx import observable, computed + from fynx import observable base = observable(5) # Correct: Create computed value - doubled = computed(lambda x: x * 2, base) + doubled = base >> (lambda x: x * 2) # Incorrect: Try to set computed value directly doubled.set(10) # Raises ValueError @@ -236,10 +230,10 @@ def set(self, value: Optional[T]) -> None: Raises: ValueError: Always raised to prevent direct modification of computed values. - Use the `computed()` function to create derived observables instead. + Use the >> operator or .then() method to create derived observables instead. See Also: - computed: Function for creating computed observables + >> operator: Modern syntax for creating computed observables _set_computed_value: Internal method used by the framework """ raise ValueError( diff --git a/fynx/observable/conditional.py b/fynx/observable/conditional.py index 6180776..9b6cb76 100644 --- a/fynx/observable/conditional.py +++ b/fynx/observable/conditional.py @@ -339,12 +339,17 @@ def _validate_inputs( # Check if condition is a valid type is_observable = isinstance(condition, Observable) + is_observable_value = hasattr(condition, "observable") and hasattr( + condition, "value" + ) is_callable = callable(condition) is_conditional = isinstance(condition, Conditional) - if not (is_observable or is_callable or is_conditional): + if not ( + is_observable or is_observable_value or is_callable or is_conditional + ): raise TypeError( - f"Condition {i} must be an Observable, callable, or ConditionalObservable, " + f"Condition {i} must be an Observable, ObservableValue, callable, or ConditionalObservable, " f"got {type(condition).__name__}" ) @@ -357,11 +362,17 @@ def _process_conditions( For callable conditions, we keep them as-is since they will be evaluated dynamically against the source value. This avoids creating unnecessary computed observables. + + For ObservableValue conditions, we unwrap them to get the underlying observable. """ processed = [] for condition in conditions: - # Keep conditions as provided; evaluation is handled dynamically - processed.append(condition) + if hasattr(condition, "observable") and hasattr(condition, "value"): + # Unwrap ObservableValue-like objects to get the underlying observable + processed.append(condition.observable) + else: + # Keep conditions as provided; evaluation is handled dynamically + processed.append(condition) return processed def _check_if_conditions_are_satisfied(self) -> bool: diff --git a/fynx/observable/descriptors.py b/fynx/observable/descriptors.py index ecc0665..b5f9639 100644 --- a/fynx/observable/descriptors.py +++ b/fynx/observable/descriptors.py @@ -93,7 +93,7 @@ class UserStore(Store): **Reactive Operators**: ```python # All operators work transparently -full_name = store.first_name | store.last_name >> (lambda f, l: f"{f} {l}") +full_name = store.first_name + store.last_name >> (lambda f, l: f"{f} {l}") is_adult = store.age >> (lambda a: a >= 18) valid_user = store.name & is_adult ``` @@ -177,7 +177,7 @@ class ObservableValue(Generic[T], ValueMixin): - **Transparent Value Access**: Behaves like the underlying value for most operations - **Observable Methods**: Provides subscription and reactive operator access - **Automatic Synchronization**: Keeps the displayed value in sync with the observable - - **Operator Support**: Enables `|`, `>>`, and other reactive operators + - **Operator Support**: Enables `+`, `>>`, and other reactive operators - **Type Safety**: Generic type parameter ensures type-safe operations Example: @@ -244,6 +244,16 @@ def observable(self) -> "Observable[T]": def __getattr__(self, name: str): return getattr(self._observable, name) + def __hash__(self) -> int: + """Make ObservableValue hashable by delegating to the underlying observable.""" + return hash(self._observable) + + def __eq__(self, other) -> bool: + """Equality comparison delegates to the underlying observable.""" + if isinstance(other, ObservableValue): + return self._observable == other._observable + return self._observable == other + class SubscriptableDescriptor(Generic[T]): """ diff --git a/fynx/observable/merged.py b/fynx/observable/merged.py index 1c06c9b..bf7001a 100644 --- a/fynx/observable/merged.py +++ b/fynx/observable/merged.py @@ -3,27 +3,32 @@ ================================================ This module provides the MergedObservable class, which combines multiple individual -observables into a single reactive unit. This enables treating related observables -as a cohesive group that updates atomically when any component changes. +observables into a single reactive computed observable. This enables treating related +observables as a cohesive group that updates atomically when any component changes. + +Merged observables are read-only computed observables that derive their value from +their source observables. They are useful for: -Merged observables are useful for: - **Coordinated Updates**: When multiple values need to change together - **Computed Relationships**: When derived values depend on multiple inputs - **Tuple Operations**: When you need to pass multiple reactive values as a unit - **State Composition**: Building complex state from simpler reactive components -The merge operation is created using the `|` operator between observables: +The merge operation is created using the `+` operator between observables: ```python from fynx import observable width = observable(10) height = observable(20) -dimensions = width | height # Creates MergedObservable +dimensions = width + height # Creates MergedObservable print(dimensions.value) # (10, 20) width.set(15) print(dimensions.value) # (15, 20) + +# Merged observables are read-only +dimensions.set((5, 5)) # Raises ValueError: Computed observables are read-only ``` """ @@ -31,19 +36,23 @@ from ..registry import _all_reactive_contexts, _func_to_contexts from .base import Observable, ReactiveContext +from .computed import ComputedObservable from .interfaces import Mergeable from .operators import OperatorMixin, TupleMixin T = TypeVar("T") -class MergedObservable(Observable[T], Mergeable[T], OperatorMixin, TupleMixin): +class MergedObservable(ComputedObservable[T], Mergeable[T], OperatorMixin, TupleMixin): """ - An observable that combines multiple observables into a single reactive tuple. + A computed observable that combines multiple observables into a single reactive tuple. - MergedObservable creates a composite observable whose value is a tuple containing + MergedObservable creates a read-only computed observable whose value is a tuple containing the current values of all source observables. When any source observable changes, - the merged observable updates its tuple value and notifies all subscribers. + the merged observable automatically recalculates its tuple value and notifies all subscribers. + + As a computed observable, MergedObservable is read-only and cannot be set directly. + Its value is always derived from its source observables, ensuring consistency. This enables treating multiple related reactive values as a single atomic unit, which is particularly useful for: @@ -62,7 +71,7 @@ class MergedObservable(Observable[T], Mergeable[T], OperatorMixin, TupleMixin): y = observable(20) # Merge them into a single reactive unit - point = x | y + point = x + y print(point.value) # (10, 20) # Computed values can work with the tuple @@ -82,8 +91,8 @@ class MergedObservable(Observable[T], Mergeable[T], OperatorMixin, TupleMixin): two observables. This provides a consistent interface for computed functions. See Also: - Observable: Base observable class - computed: For creating derived values from merged observables + ComputedObservable: Base computed observable class + >> operator: For creating derived values from merged observables """ def __init__(self, *observables: "Observable") -> None: @@ -97,12 +106,19 @@ def __init__(self, *observables: "Observable") -> None: Raises: ValueError: If no observables are provided """ - # Call parent constructor with a key and initial tuple value + if not observables: + raise ValueError("At least one observable must be provided for merging") + + # Call ComputedObservable constructor with appropriate parameters initial_tuple = tuple(obs.value for obs in observables) + # Create a computation function that combines the source observables + def compute_merged_value(): + return tuple(obs.value for obs in observables) + # NOTE: MyPy's generics can't perfectly model this complex inheritance pattern # where T represents a tuple type in the subclass but a single value in the parent - super().__init__("merged", initial_tuple) # type: ignore + super().__init__("merged", initial_tuple, compute_merged_value) # type: ignore self._source_observables = list(observables) self._cached_tuple = None # Cache for tuple value @@ -111,9 +127,10 @@ def update_merged(): # Invalidate cache and update value self._cached_tuple = None new_value = tuple(obs.value for obs in self._source_observables) - # Use the parent set method to trigger our own observers - super(MergedObservable, self).set(new_value) + # Use the computed observable's internal method to update value + self._set_computed_value(new_value) + # Set up dependency tracking for each source observable for obs in self._source_observables: obs.add_observer(update_merged) @@ -133,7 +150,7 @@ def value(self): ```python x = Observable("x", 10) y = Observable("y", 20) - merged = x | y + merged = x + y print(merged.value) # (10, 20) x.set(15) @@ -145,25 +162,6 @@ def value(self): return self._cached_tuple - def set(self, value): - """ - Override set to invalidate cache. - - This method is not typically used directly on merged observables, - as they derive their value from source observables. However, if you - need to manually set a merged observable's value, this method ensures - the internal cache is properly invalidated. - - Args: - value: The new tuple value to set - - Note: - Manually setting merged observable values is uncommon. Usually, - you update the source observables instead. - """ - self._cached_tuple = None - super().set(value) - def __enter__(self): """ Context manager entry for reactive blocks. @@ -229,12 +227,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): """ pass - def __or__(self, other: "Observable") -> "MergedObservable": + def __add__(self, other: "Observable") -> "MergedObservable": """ - Chain merging with another observable using the | operator. + Chain merging with another observable using the + operator. Enables fluent syntax for building up merged observables incrementally. - Each | operation creates a new MergedObservable containing all previous + Each + operation creates a new MergedObservable containing all previous observables plus the new one. Args: @@ -265,7 +263,7 @@ def subscribe(self, func: Callable) -> "MergedObservable[T]": ```python x = Observable("x", 1) y = Observable("y", 2) - coords = x | y + coords = x + y def on_coords_change(x_val, y_val): print(f"Coordinates: ({x_val}, {y_val})") @@ -329,7 +327,7 @@ def unsubscribe(self, func: Callable) -> None: def handler(x, y): print(f"Changed: {x}, {y}") - coords = x | y + coords = x + y coords.subscribe(handler) # Later, unsubscribe diff --git a/fynx/observable/operations.py b/fynx/observable/operations.py index d696418..e6be116 100644 --- a/fynx/observable/operations.py +++ b/fynx/observable/operations.py @@ -8,8 +8,8 @@ The operations provide a fluent, readable API for reactive programming: - `then(func)` - Transform values (equivalent to `>>` operator) -- `alongside(other)` - Merge observables (equivalent to `|` operator) -- `also(condition)` - Compose boolean conditions with AND (equivalent to `&` operator) +- `alongside(other)` - Merge observables (equivalent to `+` operator) +- `requiring(condition)` - Compose boolean conditions with AND (equivalent to `&` operator) - `negate()` - Boolean negation (equivalent to `~` operator) - `either(other)` - OR logic for boolean conditions """ @@ -163,7 +163,7 @@ def alongside(self, other: "Observable") -> "Observable": # Standard case: combine two observables return MergedObservable(self, other) # type: ignore - def also(self, *conditions) -> "Observable": + def requiring(self, *conditions) -> "Observable": """ Compose this observable with conditions using AND logic. @@ -179,7 +179,7 @@ def also(self, *conditions) -> "Observable": Example: ```python # Compose multiple conditions - result = data.also(lambda x: x > 0, is_ready, other_condition) + result = data.requiring(lambda x: x > 0, is_ready, other_condition) ``` """ from .conditional import ConditionalObservable diff --git a/fynx/observable/operators.py b/fynx/observable/operators.py index 05fe49f..cce8adf 100644 --- a/fynx/observable/operators.py +++ b/fynx/observable/operators.py @@ -16,7 +16,7 @@ - `observable >> function` - Transform values reactively - `observable & condition` - Filter values conditionally -- `obs1 | obs2 | obs3` - Combine observables +- `obs1 + obs2 + obs3` - Combine observables This approach makes reactive code more declarative and easier to understand. @@ -33,14 +33,14 @@ valid_data = data & is_valid ``` -**Combine (`|`)**: Merge multiple observables into tuples +**Combine (`+`)**: Merge multiple observables into tuples ```python -coordinates = x | y | z +coordinates = x + y + z ``` These operators work together to create complex reactive pipelines: ```python -result = (x | y) >> (lambda a, b: a + b) & (total >> (lambda t: t > 10)) +result = (x + y) >> (lambda a, b: a + b) & (total >> (lambda t: t > 10)) ``` Operator Mixins @@ -48,7 +48,7 @@ This module also provides mixin classes that consolidate operator overloading logic: -**OperatorMixin**: Provides common reactive operators (__or__, __rshift__, __and__, __invert__) +**OperatorMixin**: Provides common reactive operators (__add__, __rshift__, __and__, __invert__) for all observable types that support reactive composition. **TupleMixin**: Adds tuple-like behavior (__iter__, __len__, __getitem__, __setitem__) for @@ -102,9 +102,9 @@ quantity = observable(1) tax_rate = observable(0.08) -subtotal = (price | quantity) >> (lambda p, q: p * q) +subtotal = (price + quantity) >> (lambda p, q: p * q) tax = subtotal >> (lambda s: s * tax_rate.value) -total = (subtotal | tax) >> (lambda s, t: s + t) +total = (subtotal + tax) >> (lambda s, t: s + t) ``` Error Handling @@ -167,11 +167,11 @@ class OperatorMixin(OperationsMixin): This mixin consolidates the operator overloading logic that was previously duplicated across multiple observable classes. It provides the core reactive - operators (__or__, __rshift__, __and__, __invert__) that enable FynX's fluent + operators (__add__, __rshift__, __and__, __invert__) that enable FynX's fluent reactive programming syntax. Classes inheriting from this mixin get automatic support for: - - Merging with `|` operator + - Merging with `+` operator - Transformation with `>>` operator - Conditional filtering with `&` operator - Boolean negation with `~` operator @@ -180,9 +180,9 @@ class OperatorMixin(OperationsMixin): need to support reactive composition operations. """ - def __or__(self, other) -> "Mergeable": + def __add__(self, other) -> "Mergeable": """ - Combine this observable with another using the | operator. + Combine this observable with another using the + operator. This creates a merged observable that contains a tuple of both values and updates automatically when either observable changes. @@ -195,6 +195,21 @@ def __or__(self, other) -> "Mergeable": """ return self.alongside(other) # type: ignore + def __radd__(self, other) -> "Mergeable": + """ + Support right-side addition for merging observables. + + This enables expressions like `other + self` to work correctly, + ensuring that merged observables can be chained properly. + + Args: + other: Another Observable to combine with + + Returns: + A MergedObservable containing both values as a tuple + """ + return other.alongside(self) # type: ignore + def __rshift__(self, func: Callable) -> "Observable": """ Apply a transformation function using the >> operator to create computed observables. @@ -223,7 +238,7 @@ def __and__(self, condition) -> "Conditional": Returns: A ConditionalObservable that filters values based on the condition """ - return self.also(condition) # type: ignore + return self.requiring(condition) # type: ignore def __invert__(self) -> "Observable[bool]": """ @@ -237,6 +252,24 @@ def __invert__(self) -> "Observable[bool]": """ return self.negate() # type: ignore + def __or__(self, other) -> "Observable": + """ + Create a logical OR condition using the | operator. + + This creates a conditional observable that only emits when the OR result + is truthy. If the initial OR result is falsy, raises ConditionalNeverMet. + + Args: + other: Another boolean observable to OR with + + Returns: + A conditional observable that only emits when OR is truthy + + Raises: + ConditionalNeverMet: If initial OR result is falsy + """ + return self.either(other) # type: ignore + class TupleMixin: """ @@ -287,7 +320,7 @@ class ValueMixin: Classes inheriting from this mixin get automatic support for: - Value-like behavior (equality, string conversion, etc.) - - Reactive operators (__or__, __and__, __invert__, __rshift__) + - Reactive operators (__add__, __and__, __invert__, __rshift__) - Transparent access to the wrapped observable """ @@ -339,13 +372,20 @@ def _unwrap_operand(self, operand): return operand.observable # type: ignore return operand - def __or__(self, other) -> "Mergeable": - """Support merging observables with | operator.""" + def __add__(self, other) -> "Mergeable": + """Support merging observables with + operator.""" unwrapped_other = self._unwrap_operand(other) # type: ignore from .merged import MergedObservable return MergedObservable(self._observable, unwrapped_other) # type: ignore[attr-defined] + def __radd__(self, other) -> "Mergeable": + """Support right-side addition for merging observables.""" + unwrapped_other = self._unwrap_operand(other) # type: ignore + from .merged import MergedObservable + + return MergedObservable(unwrapped_other, self._observable) # type: ignore[attr-defined] + def __and__(self, condition) -> "Conditional": """Support conditional observables with & operator.""" unwrapped_condition = self._unwrap_operand(condition) # type: ignore @@ -391,12 +431,12 @@ def rshift_operator(obs: "Observable[T]", func: Callable[..., U]) -> "Observable The optimization uses a cost functional C(σ) = α·|Dep(σ)| + β·E[Updates(σ)] + γ·depth(σ) to find semantically equivalent observables with minimal computational cost. - For merged observables (created with `|`), the function receives multiple arguments + For merged observables (created with `+`), the function receives multiple arguments corresponding to the tuple values. For single observables, it receives one argument. Args: obs: The source observable(s) to transform. Can be a single Observable or - a MergedObservable (from `|` operator). + a MergedObservable (from `+` operator). func: A pure function that transforms the observable value(s). For merged observables, receives unpacked tuple values as separate arguments. @@ -417,8 +457,8 @@ def rshift_operator(obs: "Observable[T]", func: Callable[..., U]) -> "Observable # Complex reactive pipelines are optimized globally width = Observable("width", 10) height = Observable("height", 20) - area = (width | height) >> (lambda w, h: w * h) - volume = (width | height | Observable("depth", 5)) >> (lambda w, h, d: w * h * d) + area = (width + height) >> (lambda w, h: w * h) + volume = (width + height + Observable("depth", 5)) >> (lambda w, h, d: w * h * d) # Shared width/height computations are factored out automatically ``` @@ -430,7 +470,7 @@ def rshift_operator(obs: "Observable[T]", func: Callable[..., U]) -> "Observable See Also: Observable.then: The method that creates computed observables - MergedObservable: For combining multiple observables with `|` + MergedObservable: For combining multiple observables with `+` optimizer: The categorical optimization system """ # Delegate to the observable's optimized _create_computed method diff --git a/fynx/optimizer/morphism.py b/fynx/optimizer/morphism.py index 9c46afd..a919721 100644 --- a/fynx/optimizer/morphism.py +++ b/fynx/optimizer/morphism.py @@ -142,7 +142,7 @@ def parse(signature: str) -> Morphism: break # Handle identity - if signature == "id": + if signature == "id" or signature == "": return Morphism.identity() # Handle single morphisms (no composition) diff --git a/fynx/optimizer/optimizer.py b/fynx/optimizer/optimizer.py index f1427db..31450a9 100644 --- a/fynx/optimizer/optimizer.py +++ b/fynx/optimizer/optimizer.py @@ -502,23 +502,23 @@ def build_from_observables(self, observables: List[Observable]) -> None: node = self.get_or_create_node(obs) - # For computed observables, add their source dependencies - if isinstance(obs, ComputedObservable) and hasattr( - obs, "_source_observable" - ): - source = obs._source_observable - if source is not None: + # For merged observables, add all source dependencies + if isinstance(obs, MergedObservable): + for source in obs._source_observables: # type: ignore + if source is None: + continue if source not in visited: queue.append(source) source_node = self.get_or_create_node(source) node.incoming.add(source_node) source_node.outgoing.add(node) - # For merged observables, add all source dependencies - elif isinstance(obs, MergedObservable): - for source in obs._source_observables: # type: ignore - if source is None: - continue + # For computed observables, add their source dependencies + elif isinstance(obs, ComputedObservable) and hasattr( + obs, "_source_observable" + ): + source = obs._source_observable + if source is not None: if source not in visited: queue.append(source) source_node = self.get_or_create_node(source) @@ -644,9 +644,11 @@ def _find_computation_chains(self) -> List[List[DependencyNode]]: # Check if this node could be the start of a chain # (its predecessor is not a computed node, or has multiple outputs) + # Special case: MergedObservable can start chains even though it's a ComputedObservable predecessor = next(iter(node.incoming)) if ( isinstance(predecessor.observable, ComputedObservable) + and not isinstance(predecessor.observable, MergedObservable) and len(predecessor.outgoing) == 1 ): continue # This is a middle node, not a start @@ -2091,7 +2093,7 @@ def optimize_reactive_graph( base = observable(5) computed1 = base >> (lambda x: x * 2) computed2 = base >> (lambda x: x + 10) - result = (computed1 | computed2) >> (lambda a, b: a + b) + result = (computed1 + computed2) >> (lambda a, b: a + b) # Get optimization statistics stats = get_graph_statistics(ctx.optimizer) diff --git a/fynx/reactive.py b/fynx/reactive.py index 38d928d..c28f764 100644 --- a/fynx/reactive.py +++ b/fynx/reactive.py @@ -134,7 +134,7 @@ def observable_handler(): # Multiple observables - merge them merged = self._targets[0] for obs in self._targets[1:]: - merged = merged | obs + merged = merged + obs def merged_handler(*values): self._invoke_reactive(*values) diff --git a/fynx/store.py b/fynx/store.py index ef4ecbf..a49f228 100644 --- a/fynx/store.py +++ b/fynx/store.py @@ -82,7 +82,7 @@ class UserStore(Store): age = observable(30) # Computed properties using the >> operator - full_name = (first_name | last_name) >> ( + full_name = (first_name + last_name) >> ( lambda fname, lname: f"{fname} {lname}" ) diff --git a/scripts/deploy_docs.sh b/scripts/deploy_docs.sh index 88b5e00..6d4d494 100755 --- a/scripts/deploy_docs.sh +++ b/scripts/deploy_docs.sh @@ -47,7 +47,8 @@ export PYTHONPATH="$PROJECT_ROOT" # Step 1: Generate HTML Documentation with MkDocs print_status "Step 1: Generating HTML documentation with MkDocs..." -if poetry run python docs/generation/scripts/generate_html.py; then +cd docs/generation +if poetry run python scripts/generate_html.py; then print_success "HTML documentation generated successfully" else print_error "Failed to generate HTML documentation" @@ -56,7 +57,7 @@ fi # Step 2: Verify documentation was built print_status "Step 2: Verifying documentation build..." -if [ -d "site" ] && [ -f "site/index.html" ]; then +if [ -d "../../site" ] && [ -f "../../site/index.html" ]; then print_success "Documentation build verified - site/ directory created" else print_error "Documentation build verification failed - site/ directory not found" @@ -65,7 +66,8 @@ fi # Step 3: Deploy to GitHub Pages print_status "Step 3: Deploying to GitHub Pages..." -if poetry run mkdocs gh-deploy -f docs/generation/mkdocs.yml --force; then +cd docs/generation +if poetry run mkdocs gh-deploy --force; then print_success "Documentation deployed to GitHub Pages successfully!" echo "" echo "🎉 Documentation collection complete!" @@ -74,12 +76,12 @@ if poetry run mkdocs gh-deploy -f docs/generation/mkdocs.yml --force; then echo " https://off-by-some.github.io/fynx/" echo "" echo "Local preview available with:" - echo " poetry run mkdocs serve -f docs/generation/mkdocs.yml" + echo " cd docs/generation && poetry run mkdocs serve" else print_error "Failed to deploy to GitHub Pages" echo "" print_warning "You can still deploy manually with:" - echo " poetry run mkdocs gh-deploy -f docs/generation/mkdocs.yml" + echo " cd docs/generation && poetry run mkdocs gh-deploy" exit 1 fi diff --git a/tests/CONVENTIONS.md b/tests/CONVENTIONS.md index 68b1806..f12ad4f 100644 --- a/tests/CONVENTIONS.md +++ b/tests/CONVENTIONS.md @@ -49,7 +49,7 @@ Here's something that changed how I think about testing reactive code: you're no Consider this simple FynX expression: ```python -total = (price | quantity) >> (lambda p, q: p * q) +total = (price + quantity) >> (lambda p, q: p * q) ``` Of course you'll check that `total.value` has the right output—that's still important. But what distinguishes testing reactive systems is that you're verifying something deeper: the relationship "total equals price times quantity" remains true as values change, as subscriptions fire, as the dependency graph updates. @@ -66,10 +66,10 @@ def test_total_maintains_price_times_quantity_relationship(): price.set(10) quantity.set(3) assert total.value == 30 # First output check - + price.set(5) assert total.value == 15 # Relationship still holds - + quantity.set(10) assert total.value == 50 # Still maintained ``` @@ -92,9 +92,9 @@ def test_observable_notifies_subscriber_on_value_change(): obs = observable(10) received = [] obs.subscribe(lambda val: received.append(val)) - + obs.set(20) - + assert received == [20] ``` @@ -107,18 +107,18 @@ def test_observable_subscription_system(): """Test the subscription system""" obs = observable(10) received1, received2 = [], [] - + # Test basic subscription sub1 = obs.subscribe(lambda val: received1.append(val)) obs.set(20) assert received1 == [20] - + # Test multiple subscribers sub2 = obs.subscribe(lambda val: received2.append(val)) obs.set(30) assert received1 == [20, 30] assert received2 == [30] - + # Test unsubscription sub1.unsubscribe() obs.set(40) @@ -143,10 +143,10 @@ def test_derived_observable_recalculates_when_source_changes(): # Arrange: Create source and derived observable source = observable(5) doubled = source >> (lambda x: x * 2) - + # Act: Change the source value source.set(10) - + # Assert: Derived value updates correctly assert doubled.value == 20 ``` @@ -171,10 +171,10 @@ Good names document your system. In fact, you should be able to understand what ```python def test_store_provides_class_level_access_to_observables(): """Store observables can be accessed and modified through class attributes""" - + def test_reactive_decorator_triggers_on_observable_change(): """Functions decorated with @reactive execute when their observable updates""" - + def test_functor_composition_preserves_transformation_order(): """Chaining transformations with >> maintains left-to-right evaluation""" ``` @@ -195,8 +195,8 @@ def create_diamond_dependency(): source = observable(10) path_a = source >> (lambda x: x + 5) path_b = source >> (lambda x: x * 2) - combined = (path_a | path_b) >> (lambda a, b: a + b) - + combined = (path_a + path_b) >> (lambda a, b: a + b) + return source, path_a, path_b, combined ``` @@ -205,9 +205,9 @@ Now tests become clear about what they're actually testing: ```python def test_diamond_dependency_updates_from_single_source(): source, path_a, path_b, combined = create_diamond_dependency() - + source.set(20) - + assert combined.value == 65 # (20 + 5) + (20 * 2) ``` @@ -224,10 +224,10 @@ def subscription_tracker(): class Tracker: def __init__(self): self.values = [] - + def record(self, value): self.values.append(value) - + return Tracker() ``` @@ -237,9 +237,9 @@ Then inject it cleanly: def test_multiple_subscribers_receive_independent_notifications(subscription_tracker): obs = observable(0) obs.subscribe(subscription_tracker.record) - + obs.set(5) - + assert subscription_tracker.values == [5] ``` @@ -266,19 +266,19 @@ def test_observable_subscription_receives_updates(): obs = observable(0) # Fresh for this test received = [] obs.subscribe(lambda val: received.append(val)) - + obs.set(5) - + assert received == [5] def test_observable_subscription_can_unsubscribe(): obs = observable(0) # Completely independent received = [] sub = obs.subscribe(lambda val: received.append(val)) - + sub.unsubscribe() obs.set(5) - + assert received == [] ``` @@ -333,13 +333,13 @@ from unittest.mock import Mock def test_reactive_pipeline_caches_transformation_results(): mock_fetcher = Mock() mock_fetcher.fetch_data.return_value = {"value": 100} - + source = observable(None) transformed = source >> (lambda _: mock_fetcher.fetch_data()) \ >> (lambda data: data["value"] * 2) - + source.set("trigger") - + assert transformed.value == 200 mock_fetcher.fetch_data.assert_called_once() ``` @@ -368,9 +368,9 @@ Parameterized tests let you verify the same behavior across different inputs wit ]) def test_observable_updates_to_new_value(initial, update, expected): obs = observable(initial) - + obs.set(obs.value + update) - + assert obs.value == expected ``` @@ -383,7 +383,7 @@ Descriptive IDs make test output readable: ```python @pytest.mark.parametrize("operator,values,expected", [ (">>", (5, lambda x: x * 2), 10), - ("|", (observable(5), observable(3)), (5, 3)), + ("+", (observable(5), observable(3)), (5, 3)), ("&", (observable(5), lambda x: x > 0), 5), ], ids=["transform", "combine", "filter"]) def test_operator_behavior(operator, values, expected): @@ -427,7 +427,7 @@ Edge cases and error conditions hide bugs that only surface in production. Test def test_derived_observable_handles_initial_none_value(): source = observable(None) transformed = source >> (lambda x: x or "default") - + assert transformed.value == "default" ``` @@ -438,10 +438,10 @@ def test_observable_handles_rapid_successive_updates(): obs = observable(0) received = [] obs.subscribe(lambda val: received.append(val)) - + for i in range(100): obs.set(i) - + assert received[-1] == 99 assert len(received) == 100 ``` @@ -451,7 +451,7 @@ def test_observable_handles_rapid_successive_updates(): ```python def test_circular_dependency_raises_error(): a = observable(1) - + with pytest.raises(ValueError, match="circular"): # Attempting to create a circular dependency # Your implementation should detect and prevent this @@ -466,18 +466,18 @@ def test_circular_dependency_raises_error(): def test_subscription_with_failing_callback_doesnt_break_observable(): obs = observable(0) successful_calls = [] - + def failing_callback(val): raise RuntimeError("Subscriber error") - + def working_callback(val): successful_calls.append(val) - + obs.subscribe(failing_callback) obs.subscribe(working_callback) - + obs.set(5) - + assert successful_calls == [5] ``` @@ -495,9 +495,9 @@ Test extremes systematically: ]) def test_value_categorization_handles_range(value, expected_category): obs = observable(value) - category = obs >> (lambda x: "zero" if x == 0 else + category = obs >> (lambda x: "zero" if x == 0 else "positive" if x > 0 else "negative") - + assert category.value == expected_category ``` @@ -528,9 +528,9 @@ def test_observable_notifies_subscriber_on_change(): obs = observable(0) received = [] obs.subscribe(lambda val: received.append(val)) - + obs.set(5) - + assert received == [5] ``` @@ -585,9 +585,9 @@ def test_store_computed_properties_react_to_observable_changes(): class TemperatureMonitor(Store): celsius = observable(0.0) fahrenheit = celsius.then(lambda c: c * 9/5 + 32) - + TemperatureMonitor.celsius = 100.0 - + assert TemperatureMonitor.fahrenheit.value == 212.0 ``` @@ -598,13 +598,13 @@ def test_store_computed_properties_react_to_observable_changes(): def test_diamond_dependency_resolves_correctly(): """Verifies that diamond-shaped dependency graphs compute correctly""" source = observable(10) - + path_a = source >> (lambda x: x + 5) path_b = source >> (lambda x: x * 2) - combined = (path_a | path_b) >> (lambda a, b: a + b) - + combined = (path_a + path_b) >> (lambda a, b: a + b) + assert combined.value == 35 # (10 + 5) + (10 * 2) - + source.set(20) assert combined.value == 65 # (20 + 5) + (20 * 2) ``` @@ -724,7 +724,7 @@ def test_reactive_chain_cleanup_breaks_cycles(): # Build a reactive chain derived1 = source >> (lambda x: x * 2) derived2 = derived1 >> (lambda x: x + 5) - final = (derived1 | derived2) >> (lambda a, b: a + b) + final = (derived1 + derived2) >> (lambda a, b: a + b) # Keep weak references to all chain elements chain_refs = [ @@ -1040,9 +1040,9 @@ def get_value_or_default(obs, default=None): # Add focused test def test_get_value_or_default_returns_default_when_observable_is_none(): obs = observable(None) - + result = get_value_or_default(obs, default="fallback") - + assert result == "fallback" ``` @@ -1061,4 +1061,4 @@ Remember: Your tests are living documentation of how FynX components should behave. Treat them with the same care you give production code, and they'll serve you well as your reactive systems grow in sophistication. -The goal isn't just to verify that your code works—it's to create a feedback loop that makes your code better. Tests that clarify, tests that guide, tests that make the next change easier. That's the practice worth cultivating. \ No newline at end of file +The goal isn't just to verify that your code works—it's to create a feedback loop that makes your code better. Tests that clarify, tests that guide, tests that make the next change easier. That's the practice worth cultivating. diff --git a/tests/conftest.py b/tests/conftest.py index 92b1da0..0e4f885 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,12 +123,12 @@ class ComplexStore(Store): total = items.then(lambda items: sum(items)) # Computed: average of items - average = (total | items.then(lambda items: len(items))).then( - lambda total, count: total / count if count > 0 else 0 + average = items.then( + lambda items: sum(items) / len(items) if len(items) > 0 else 0 ) # Computed: scaled total - scaled_total = (total | multiplier).then( + scaled_total = (total + multiplier).then( lambda total, multiplier: total * multiplier ) diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index c84dca0..742406e 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -37,11 +37,11 @@ def test_observable_updates_value_when_set_is_called(): @pytest.mark.observable @pytest.mark.operators def test_pipe_operator_combines_observables_into_tuple(): - """Pipe operator (|) combines multiple observables into a tuple of current values""" + """Pipe operator (+) combines multiple observables into a tuple of current values""" obs1 = Observable("first", "hello") obs2 = Observable("second", "world") - merged = obs1 | obs2 + merged = obs1 + obs2 assert merged.value == ("hello", "world") assert len(merged) == 2 @@ -54,7 +54,7 @@ def test_merged_observable_context_manager_enables_unpacking(): """Merged observable context manager enables tuple unpacking of values""" obs1 = Observable("first", "hello") obs2 = Observable("second", "world") - merged = obs1 | obs2 + merged = obs1 + obs2 with merged: val1, val2 = merged.value @@ -69,7 +69,7 @@ def test_merged_observable_updates_when_any_source_changes(): """Merged observable updates its tuple value when any source observable changes""" obs1 = Observable("first", "hello") obs2 = Observable("second", "world") - merged = obs1 | obs2 + merged = obs1 + obs2 obs1.set("hi") assert merged.value == ("hi", "world") @@ -87,7 +87,7 @@ def test_pipe_operator_chains_for_multiple_observables(): obs2 = Observable("second", "world") obs3 = Observable("third", "!") - chained = obs1 | obs2 | obs3 + chained = obs1 + obs2 + obs3 assert chained.value == ("hello", "world", "!") assert len(chained) == 3 @@ -101,7 +101,7 @@ def test_chained_merged_observable_updates_when_any_source_changes(): obs1 = Observable("first", "hello") obs2 = Observable("second", "world") obs3 = Observable("third", "!") - chained = obs1 | obs2 | obs3 + chained = obs1 + obs2 + obs3 obs1.set("hi") assert chained.value == ("hi", "world", "!") @@ -120,8 +120,8 @@ def test_merged_observable_can_be_extended_with_additional_observables(): obs3 = Observable("third", "!") obs4 = Observable("fourth", "extra") - chained = obs1 | obs2 | obs3 - chained2 = chained | obs4 + chained = obs1 + obs2 + obs3 + chained2 = chained + obs4 assert chained2.value == ("hello", "world", "!", "extra") assert len(chained2) == 4 @@ -137,8 +137,8 @@ def test_extending_merged_observable_preserves_original(): obs3 = Observable("third", "!") obs4 = Observable("fourth", "extra") - chained = obs1 | obs2 | obs3 - chained2 = chained | obs4 + chained = obs1 + obs2 + obs3 + chained2 = chained + obs4 # Verify the original chained is unaffected assert chained.value == ("hello", "world", "!") @@ -187,7 +187,7 @@ class TestStore(Store): TestStore.count = 5 TestStore.name = "updated" - merged = TestStore.count | TestStore.name + merged = TestStore.count + TestStore.name assert merged.value == (5, "updated") @@ -256,7 +256,7 @@ def test_merged_observable_subscription_notifies_on_any_source_change(): def on_combined_change(name, age): callback_calls.append(f"Name: {name}, Age: {age}") - current_name_and_age = current_name | current_age + current_name_and_age = current_name + current_age current_name_and_age.subscribe(on_combined_change) current_name.set("Charlie") @@ -279,7 +279,7 @@ def test_merged_observable_unsubscription_stops_notifications(): def on_combined_change(name, age): callback_calls.append(f"Name: {name}, Age: {age}") - current_name_and_age = current_name | current_age + current_name_and_age = current_name + current_age current_name_and_age.subscribe(on_combined_change) current_name.set("Charlie") current_age.set(31) @@ -506,7 +506,7 @@ class TestStore(Store): def on_context_change(name, age): callback_calls.append(f"Context: name={name}, age={age}") - with TestStore.name | TestStore.age as react: + with TestStore.name + TestStore.age as react: react(on_context_change) assert callback_calls == ["Context: name=Alice, age=30"] @@ -526,7 +526,7 @@ class TestStore(Store): def on_context_change(name, age): callback_calls.append(f"Context: name={name}, age={age}") - with TestStore.name | TestStore.age as react: + with TestStore.name + TestStore.age as react: react(on_context_change) TestStore.name = "Bob" TestStore.age = 31 @@ -589,7 +589,7 @@ def test_then_operator_creates_computed_observable_from_merged_sources(): """Then operator creates computed observable that transforms merged observable sources""" num1 = observable(3) num2 = observable(4) - combined = num1 | num2 + combined = num1 + num2 summed = combined.then(lambda a, b: a + b) assert summed.value == 7 # 3 + 4 @@ -608,7 +608,7 @@ def test_computed_observables_can_be_chained(): """Computed observables can be chained to create transformation pipelines""" num1 = observable(3) num2 = observable(4) - combined = num1 | num2 + combined = num1 + num2 summed = combined.then(lambda a, b: a + b) final = summed.then(lambda s: s * 2) @@ -642,7 +642,7 @@ def test_rshift_operator_chains_transformations_on_merged_observables(): """Right shift operator (>>) chains transformations on merged observables""" num1 = observable(3) num2 = observable(4) - combined = num1 | num2 + combined = num1 + num2 summed = combined >> (lambda a, b: a + b) formatted = summed >> (lambda s: f"Sum: {s}") diff --git a/tests/integration/test_circular_dependency.py b/tests/integration/test_circular_dependency.py index 4e31501..a36f1a0 100644 --- a/tests/integration/test_circular_dependency.py +++ b/tests/integration/test_circular_dependency.py @@ -26,7 +26,7 @@ def test_circular_dependency_with_conditional_chains(): is_valid_age = age_obs >> (lambda age: 0 <= age <= 150) # Create combined boolean observable for profile validity - profile_is_valid = (is_valid_email | is_valid_age) >> ( + profile_is_valid = (is_valid_email + is_valid_age) >> ( lambda email_valid, age_valid: email_valid and age_valid ) @@ -64,7 +64,7 @@ def test_circular_dependency_with_complex_chains(): is_valid_age = TestStore.age >> (lambda age: 0 <= age <= 150) # Conditional chain - profile_is_valid = (is_valid_email | is_valid_age) >> ( + profile_is_valid = (is_valid_email + is_valid_age) >> ( lambda email_valid, age_valid: email_valid and age_valid ) @@ -127,17 +127,17 @@ def test_complex_computed_web(): c = observable(3) # Create computed observables that depend on each other - sum_ab = (a | b) >> (lambda x, y: x + y) - sum_bc = (b | c) >> (lambda x, y: x + y) - sum_ac = (a | c) >> (lambda x, y: x + y) + sum_ab = (a + b) >> (lambda x, y: x + y) + sum_bc = (b + c) >> (lambda x, y: x + y) + sum_ac = (a + c) >> (lambda x, y: x + y) # Create higher-level computed that depend on the sums - total1 = (sum_ab | sum_bc) >> (lambda x, y: x + y) - total2 = (sum_bc | sum_ac) >> (lambda x, y: x + y) - total3 = (sum_ac | sum_ab) >> (lambda x, y: x + y) + total1 = (sum_ab + sum_bc) >> (lambda x, y: x + y) + total2 = (sum_bc + sum_ac) >> (lambda x, y: x + y) + total3 = (sum_ac + sum_ab) >> (lambda x, y: x + y) # Create final aggregator - grand_total = (total1 | total2 | total3) >> (lambda x, y, z: x + y + z) + grand_total = (total1 + total2 + total3) >> (lambda x, y, z: x + y + z) # Change one base observable and verify everything updates a.set(10) diff --git a/tests/integration/test_optimizer.py b/tests/integration/test_optimizer.py index c8840fc..99ce86e 100644 --- a/tests/integration/test_optimizer.py +++ b/tests/integration/test_optimizer.py @@ -134,7 +134,7 @@ def test_chain_fusion_with_merges(self): b = observable(2) # Create merged observable, then chain - merged = a | b + merged = a + b chain = ( merged >> (lambda x, y: x + y) # Add them @@ -192,7 +192,7 @@ def test_diamond_dependency_pattern(self): left = base >> (lambda x: x + 1) right = base >> (lambda x: x * 2) - combine = (left | right) >> (lambda l, r: l + r) + combine = (left + right) >> (lambda l, r: l + r) results, optimizer = optimize_reactive_graph([combine]) @@ -789,7 +789,7 @@ def test_complex_reactive_graph_optimization_performs_fusions(self): # Complex computation using multiple fields final_score = ( - (score | bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) + (score + bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) ) # Conditional display @@ -835,7 +835,7 @@ def test_complex_reactive_graph_optimization_reduces_graph_size(self): # Complex computation using multiple fields final_score = ( - (score | bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) + (score + bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) ) # Conditional display @@ -879,7 +879,7 @@ def test_complex_reactive_graph_optimization_preserves_computation_correctness( # Complex computation using multiple fields final_score = ( - (score | bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) + (score + bonus_multiplier) >> (lambda s, m: s * m) >> (lambda s: int(s)) ) # Conditional display diff --git a/tests/integration/test_reactive_system_interactions.py b/tests/integration/test_reactive_system_interactions.py index 4defd30..19651dc 100644 --- a/tests/integration/test_reactive_system_interactions.py +++ b/tests/integration/test_reactive_system_interactions.py @@ -118,6 +118,9 @@ def test_counter_with_bounds_checking_handles_dynamic_bounds_changes( # Arrange - counter_with_limits fixture provides (counter, min_val, max_val, is_valid) counter, min_val, max_val, is_valid = counter_with_limits + # Act - Set counter to 25 first + counter.set(25) + # Act - Change bounds dynamically min_val.set(10) @@ -195,7 +198,7 @@ class ShoppingCart(Store): total_price = observable(0.0) discount_percent = observable(0.0) - final_price = (total_price | discount_percent).then( + final_price = (total_price + discount_percent).then( lambda price, discount: price * (1 - discount / 100) ) @@ -242,7 +245,7 @@ class InventoryStore(Store): widgets = observable(100) gadgets = observable(50) - total_items = (widgets | gadgets).then(lambda w, g: w + g) + total_items = (widgets + gadgets).then(lambda w, g: w + g) class PricingStore(Store): base_price_per_item = observable(10.0) @@ -258,8 +261,8 @@ def connect_inventory(self, inventory_store): self.inventory_total = inventory_store.total_items self.effective_price = ( self.base_price_per_item - | self.inventory_total - | self.bulk_discount_threshold + + self.inventory_total + + self.bulk_discount_threshold ).then( lambda base, total, threshold: ( base * 0.9 if total >= threshold else base diff --git a/tests/integration/test_readme.py b/tests/integration/test_readme.py index 5b980b8..887abc2 100644 --- a/tests/integration/test_readme.py +++ b/tests/integration/test_readme.py @@ -22,7 +22,7 @@ class CartStore(Store): price_per_item = observable(10.0) # Reactive computation - total_price = (CartStore.item_count | CartStore.price_per_item) >> ( + total_price = (CartStore.item_count + CartStore.price_per_item) >> ( lambda count, price: count * price ) @@ -76,14 +76,14 @@ def test_transforming_data_with_rshift(self): assert doubled.value == 10 def test_combining_observables_with_or(self): - """Test the | operator examples.""" + """Test the + operator examples.""" class User(Store): first_name = observable("John") last_name = observable("Doe") # Combine and transform - full_name = (User.first_name | User.last_name) >> (lambda f, l: f"{f} {l}") + full_name = (User.first_name + User.last_name) >> (lambda f, l: f"{f} {l}") assert full_name.value == "John Doe" User.first_name = "Jane" @@ -210,7 +210,7 @@ def test_product_examples(self): last_name = observable("Doe") # Product creates a tuple observable - full_name = (first_name | last_name) >> (lambda f, l: f"{f} {l}") + full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") assert full_name.value == "Jane Doe" first_name.set("John") # full_name automatically becomes "John Doe" @@ -222,9 +222,9 @@ def test_product_associativity(self): b = observable(2) c = observable(3) - # Associativity: (a | b) | c ≅ a | (b | c) - left_assoc = (a | b) | c # ((1, 2), 3) -> (1, 2, 3) - right_assoc = a | (b | c) # (1, (2, 3)) -> (1, 2, 3) + # Associativity: (a + b) + c ≅ a + (b + c) + left_assoc = (a + b) + c # ((1, 2), 3) -> (1, 2, 3) + right_assoc = a + (b + c) # (1, (2, 3)) -> (1, 2, 3) # Both should flatten to the same tuple assert left_assoc.value == (1, 2, 3) @@ -291,7 +291,7 @@ def test_complex_composition(self): discount = observable(0.1) is_valid = quantity >> (lambda q: q > 0) - total = ((price | quantity) >> (lambda p, q: p * q)) & is_valid + total = ((price + quantity) >> (lambda p, q: p * q)) & is_valid discounted = total >> ( lambda t: t * (1 - discount.value) if t is not None else 0 ) diff --git a/tests/test_factories.py b/tests/test_factories.py index 06d412e..850edf1 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -18,7 +18,7 @@ def create_diamond_dependency(): source = observable(10) path_a = source >> (lambda x: x + 5) path_b = source >> (lambda x: x * 2) - combined = (path_a | path_b) >> (lambda a, b: a + b) + combined = (path_a + path_b) >> (lambda a, b: a + b) return source, path_a, path_b, combined @@ -64,7 +64,7 @@ def create_user_profile_store(): class UserProfile(Store): first_name = observable("") last_name = observable("") - full_name = (first_name | last_name).then(lambda f, l: f"{f} {l}".strip()) + full_name = (first_name + last_name).then(lambda f, l: f"{f} {l}".strip()) return UserProfile @@ -79,7 +79,7 @@ def create_counter_with_limits(): min_val = observable(0) max_val = observable(100) - is_valid = (counter | min_val | max_val).then( + is_valid = (counter + min_val + max_val).then( lambda c, min_v, max_v: min_v <= c <= max_v ) @@ -97,10 +97,10 @@ def create_reactive_filter_chain(): multiplier = observable(2) # Create filtered observable that only passes values matching predicate - filtered = (source | predicate).then(lambda val, pred: val if pred(val) else None) + filtered = (source + predicate).then(lambda val, pred: val if pred(val) else None) # Transform non-None values - result = (filtered | multiplier).then( + result = (filtered + multiplier).then( lambda filtered_val, mult: ( filtered_val * mult if filtered_val is not None else 0 ) diff --git a/tests/unit/observable/base/test_core.py b/tests/unit/observable/base/test_core.py index 055271a..7849665 100644 --- a/tests/unit/observable/base/test_core.py +++ b/tests/unit/observable/base/test_core.py @@ -193,7 +193,7 @@ def test_merged_observable_provides_tuple_like_access(): # Arrange obs1 = Observable("key1", "a") obs2 = Observable("key2", "b") - merged = obs1 | obs2 + merged = obs1 + obs2 # Act & Assert assert merged.value == ("a", "b") @@ -207,7 +207,7 @@ def test_merged_observable_supports_iteration(): # Arrange obs1 = Observable("key1", 1) obs2 = Observable("key2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 # Act values = list(merged) @@ -224,7 +224,7 @@ def test_merged_observable_provides_index_access(): # Arrange obs1 = Observable("key1", "first") obs2 = Observable("key2", "second") - merged = obs1 | obs2 + merged = obs1 + obs2 # Act & Assert assert merged[0] == "first" @@ -239,7 +239,7 @@ def test_merged_observable_index_assignment_updates_source(): # Arrange obs1 = Observable("key1", "first") obs2 = Observable("key2", "second") - merged = obs1 | obs2 + merged = obs1 + obs2 # Act merged[0] = "updated_first" @@ -260,7 +260,7 @@ def test_pipe_operator_chains_multiple_merges(): obs3 = Observable("key3", 3) # Act - merged = obs1 | obs2 | obs3 + merged = obs1 + obs2 + obs3 # Assert assert len(merged) == 3 @@ -278,7 +278,7 @@ def test_merged_observable_maintains_length_invariant(): obs3 = Observable("c", 3) # Act - Create merge chain - merged = obs1 | obs2 | obs3 + merged = obs1 + obs2 + obs3 # Assert - Length invariant holds assert len(merged) == 3 @@ -302,7 +302,7 @@ def test_context_manager_preserves_merged_state_during_execution(): # Arrange obs1 = Observable("x", 5) obs2 = Observable("y", 10) - merged = obs1 | obs2 + merged = obs1 + obs2 execution_log = [] @@ -328,7 +328,7 @@ def test_context_manager_provides_unpacking_access(): # Arrange obs1 = Observable("key1", "hello") obs2 = Observable("key2", "world") - merged = obs1 | obs2 + merged = obs1 + obs2 # Act & Assert with merged as context: @@ -349,7 +349,7 @@ def test_context_manager_enables_reactive_callbacks(): # Arrange obs1 = Observable("key1", 10) obs2 = Observable("key2", 20) - merged = obs1 | obs2 + merged = obs1 + obs2 execution_count = 0 def reactive_callback(a, b): @@ -384,7 +384,7 @@ def test_context_manager_allows_value_access_without_reactivity(): # Arrange obs1 = Observable("key1", "test") obs2 = Observable("key2", "value") - merged = obs1 | obs2 + merged = obs1 + obs2 # Act & Assert with merged as context: @@ -569,7 +569,7 @@ def test_merged_observable_value_derives_from_source_observables(): """MergedObservable value derives from source observables, not direct set operations""" obs1 = Observable("obs1", 1) obs2 = Observable("obs2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 # Initial value should be tuple of source values assert merged.value == (1, 2) @@ -579,10 +579,13 @@ def test_merged_observable_value_derives_from_source_observables(): obs2.set(4) assert merged.value == (3, 4) - # Direct set on merged observable doesn't affect the derived value - # (this is the expected behavior - merged observables derive from sources) - merged.set((5, 6)) - assert merged.value == (3, 4) # Still reflects source values + # Merged observables are read-only computed observables + # Attempting to set them directly should raise ValueError + with pytest.raises(ValueError, match="Computed observables are read-only"): + merged.set((5, 6)) + + # Value should still reflect source values + assert merged.value == (3, 4) @pytest.mark.unit @@ -593,7 +596,7 @@ def test_merged_observable_cleanup_removes_empty_function_mappings(): obs1 = Observable("obs1", 1) obs2 = Observable("obs2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 def test_func(): pass diff --git a/tests/unit/observable/base/test_observable_notification_system.py b/tests/unit/observable/base/test_observable_notification_system.py index 34c4bdf..5f8dfbf 100644 --- a/tests/unit/observable/base/test_observable_notification_system.py +++ b/tests/unit/observable/base/test_observable_notification_system.py @@ -130,7 +130,7 @@ def test_merged_observable_subscription_notifies_on_any_source_change(): # Arrange obs1 = Observable("key1", 1) obs2 = Observable("key2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 callback_executed = False received_values = None @@ -158,7 +158,7 @@ def test_merged_observable_subscription_receives_current_values(): # Arrange obs1 = Observable("key1", "hello") obs2 = Observable("key2", "world") - merged = obs1 | obs2 + merged = obs1 + obs2 received_args = None @@ -183,7 +183,7 @@ def test_merged_observable_subscribe_supports_chaining(): # Arrange obs1 = Observable("key1", 1) obs2 = Observable("key2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 # Act result = merged.subscribe(lambda: None) @@ -200,7 +200,7 @@ def test_merged_observable_unsubscribe_removes_specific_callback(): # Arrange obs1 = Observable("key1", 1) obs2 = Observable("key2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 call_count = 0 @@ -232,7 +232,7 @@ def test_merged_observable_unsubscribe_handles_nonexistent_callback(): # Arrange obs1 = Observable("key1", 1) obs2 = Observable("key2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 def callback(): pass diff --git a/tests/unit/observable/test_conditional.py b/tests/unit/observable/test_conditional.py index 408404d..24cbc40 100644 --- a/tests/unit/observable/test_conditional.py +++ b/tests/unit/observable/test_conditional.py @@ -257,7 +257,7 @@ def test_conditional_observable_raises_error_for_invalid_condition_type(): with pytest.raises( TypeError, - match="Condition 0 must be an Observable, callable, or ConditionalObservable", + match="Condition 0 must be an Observable, ObservableValue, callable, or ConditionalObservable", ): ConditionalObservable(source, 42) @@ -300,7 +300,7 @@ def test_conditional_observable_with_callable_condition_filters_tuple_values(): """ConditionalObservable with callable condition filters tuple values correctly.""" source1 = Observable("s1", 1) source2 = Observable("s2", 2) - merged = source1 | source2 + merged = source1 + source2 def check_sum(a, b): return a + b > 2 @@ -364,7 +364,7 @@ class UnknownCondition: # Should raise TypeError when trying to create conditional with invalid condition type with pytest.raises( TypeError, - match="Condition 0 must be an Observable, callable, or ConditionalObservable", + match="Condition 0 must be an Observable, ObservableValue, callable, or ConditionalObservable", ): ConditionalObservable(source, unknown_cond) @@ -444,7 +444,7 @@ def test_conditional_observable_debug_info_handles_callable_with_tuple(): """get_debug_info handles callable conditions with tuple source values.""" source1 = Observable("s1", 1) source2 = Observable("s2", 2) - merged = source1 | source2 + merged = source1 + source2 def check_values(a, b): return a + b > 2 @@ -531,7 +531,7 @@ def test_conditional_observable_debug_info_handles_callable_with_tuple_source(): """Test get_debug_info() with callable condition and tuple source (line 698)""" source1 = Observable("s1", 1) source2 = Observable("s2", 2) - merged = source1 | source2 + merged = source1 + source2 def check_tuple(a, b): return a + b > 2 @@ -569,3 +569,55 @@ def check_single(x): ] assert len(callable_conditions) == 1 assert callable_conditions[0]["result"] is True # 5 > 3 + + +@pytest.mark.unit +@pytest.mark.observable +@pytest.mark.store +def test_conditional_observable_works_with_observable_value_from_stores(): + """Conditional observables work with ObservableValue objects from store computed observables.""" + from fynx import Store, observable + + class TestStore(Store): + value = observable(10) + is_positive = value >> (lambda x: x > 0) + is_even = value >> (lambda x: x % 2 == 0) + + # Create conditional observable using computed observables from store + # These are ObservableValue objects, not raw observables + filtered = TestStore.value & TestStore.is_positive & TestStore.is_even + + # Should work correctly + assert isinstance(filtered, ConditionalObservable) + assert filtered.is_active is True # 10 is positive and even + assert filtered.value == 10 + + # Test updates + TestStore.value = 7 # Positive but odd + assert filtered.is_active is False # Conditions not met + + TestStore.value = 8 # Positive and even + assert filtered.is_active is True + assert filtered.value == 8 + + TestStore.value = -4 # Negative but even + assert filtered.is_active is False # Conditions not met + + +@pytest.mark.unit +@pytest.mark.observable +@pytest.mark.store +def test_conditional_observable_validation_accepts_observable_value(): + """Conditional observable validation accepts ObservableValue objects.""" + from fynx import Store, observable + + class TestStore(Store): + value = observable(5) + condition = value >> (lambda x: x > 3) + + # This should not raise a TypeError + conditional = TestStore.value & TestStore.condition + + assert isinstance(conditional, ConditionalObservable) + assert conditional.is_active is True + assert conditional.value == 5 diff --git a/tests/unit/observable/test_descriptors.py b/tests/unit/observable/test_descriptors.py index 85ed918..9e4a97f 100644 --- a/tests/unit/observable/test_descriptors.py +++ b/tests/unit/observable/test_descriptors.py @@ -88,8 +88,8 @@ def test_observable_value_supports_reactive_operators(): doubled = obs_value1 >> (lambda x: x * 2) assert doubled.value == 20 - # Test | operator (merge) - merged = obs_value1 | obs_value2 + # Test + operator (merge) + merged = obs_value1 + obs_value2 assert merged.value == (10, 20) # Test & operator (conditional) diff --git a/tests/unit/observable/test_operations.py b/tests/unit/observable/test_operations.py index 1c42e7a..5489dc5 100644 --- a/tests/unit/observable/test_operations.py +++ b/tests/unit/observable/test_operations.py @@ -65,7 +65,7 @@ def test_then_method_with_merged_observable_registers_with_context(): """then() method with merged observable registers with optimization context.""" obs1 = Observable("obs1", 3) obs2 = Observable("obs2", 4) - merged = obs1 | obs2 + merged = obs1 + obs2 # Create optimization context with OptimizationContext() as context: diff --git a/tests/unit/observable/test_operators.py b/tests/unit/observable/test_operators.py index ca85bba..beeb77a 100644 --- a/tests/unit/observable/test_operators.py +++ b/tests/unit/observable/test_operators.py @@ -1,6 +1,7 @@ import pytest from fynx.observable.base import Observable +from fynx.observable.conditional import ConditionalNeverMet from fynx.observable.descriptors import ObservableValue from fynx.observable.operators import and_operator @@ -8,15 +9,90 @@ @pytest.mark.unit @pytest.mark.observable @pytest.mark.operators -def test_or_operator_merges_observables_into_tuple(): - """Merging with | produces a tuple of current values.""" +def test_or_operator_creates_logical_or_condition(): + """The | operator creates a logical OR condition between observables.""" # Arrange - a = Observable("a", 2) - b = Observable("b", 3) + is_error = Observable("error", True) + is_warning = Observable("warning", False) + # Act - merged = a | b + needs_attention = is_error | is_warning + # Assert - assert merged.value == (2, 3) + assert needs_attention.value is True # True OR False = True + + # Test updates - keep at least one True to maintain the condition + is_warning.set(True) + assert needs_attention.value is True # True OR True = True + + # Test with both True + is_error.set(True) + assert needs_attention.value is True # True OR True = True + + +@pytest.mark.unit +@pytest.mark.observable +@pytest.mark.operators +def test_or_operator_with_falsy_initial_values(): + """The | operator raises ConditionalNeverMet when both initial values are falsy.""" + # Arrange + is_error = Observable("error", False) + is_warning = Observable("warning", False) + + # Act & Assert + with pytest.raises(ConditionalNeverMet): + needs_attention = is_error | is_warning + _ = needs_attention.value + + +@pytest.mark.unit +@pytest.mark.observable +@pytest.mark.operators +def test_or_operator_chaining(): + """The | operator can be chained for multiple OR conditions.""" + # Arrange + is_error = Observable("error", True) + is_warning = Observable("warning", False) + is_critical = Observable("critical", False) + + # Act + needs_attention = is_error | is_warning | is_critical + + # Assert + assert needs_attention.value is True # True OR False OR False = True + + # Test updates - keep at least one True to maintain the condition + is_warning.set(True) + assert needs_attention.value is True # True OR True OR False = True + + # Test with all True + is_critical.set(True) + assert needs_attention.value is True # True OR True OR True = True + + +@pytest.mark.unit +@pytest.mark.observable +@pytest.mark.operators +def test_or_operator_equivalent_to_either(): + """The | operator is equivalent to calling .either() method.""" + # Arrange + is_error = Observable("error", True) + is_warning = Observable("warning", False) + + # Act + needs_attention_or = is_error | is_warning + needs_attention_either = is_error.either(is_warning) + + # Assert + assert needs_attention_or.value == needs_attention_either.value + + # Test updates - keep at least one True to maintain the condition + is_warning.set(True) + assert needs_attention_or.value == needs_attention_either.value + + # Test with both True + is_error.set(True) + assert needs_attention_or.value == needs_attention_either.value @pytest.mark.unit @@ -27,7 +103,7 @@ def test_rshift_operator_applies_function_to_merged_arguments(): # Arrange a = Observable("a", 2) b = Observable("b", 3) - merged = a | b + merged = a + b # Act prod = merged >> (lambda x, y: x * y) # Assert @@ -145,7 +221,7 @@ def test_merged_observable_getitem_raises_error_when_no_value(): obs1 = Observable("obs1", 1) obs2 = Observable("obs2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 # Set value to None to trigger the error path merged._value = None @@ -162,7 +238,7 @@ def test_merged_observable_setitem_raises_error_for_out_of_range_index(): obs1 = Observable("obs1", 1) obs2 = Observable("obs2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 # Try to set index that's out of range with pytest.raises(IndexError, match="Index out of range"): @@ -347,7 +423,7 @@ def test_merged_observable_unsubscribe_cleanup_empty_mapping(): obs1 = Observable("obs1", 1) obs2 = Observable("obs2", 2) - merged = obs1 | obs2 + merged = obs1 + obs2 def test_func(): pass diff --git a/tests/unit/optimizer/morphism/test_morphism.py b/tests/unit/optimizer/morphism/test_morphism.py index 0d17404..faa5d50 100644 --- a/tests/unit/optimizer/morphism/test_morphism.py +++ b/tests/unit/optimizer/morphism/test_morphism.py @@ -20,8 +20,8 @@ def test_morphism_parser_handles_edge_cases_with_empty_parts(): # Test cases that should result in special morphisms assert MorphismParser.parse(" ∘ ") == Morphism.single("∘") assert MorphismParser.parse(" ∘ ∘ ") == Morphism.single("∘ ∘") - assert MorphismParser.parse("") == Morphism.single("") - assert MorphismParser.parse(" ") == Morphism.single("") + assert MorphismParser.parse("") == Morphism.identity() + assert MorphismParser.parse(" ") == Morphism.identity() @pytest.mark.unit diff --git a/tests/unit/optimizer/optimizer/test_optimizer.py b/tests/unit/optimizer/optimizer/test_optimizer.py index 994941f..f268546 100644 --- a/tests/unit/optimizer/optimizer/test_optimizer.py +++ b/tests/unit/optimizer/optimizer/test_optimizer.py @@ -39,7 +39,7 @@ def test_build_from_observables_handles_merged_sources(): # Arrange a = observable(1) b = observable(2) - merged = a | b + merged = a + b s = merged >> (lambda x, y: x + y) # Act graph = ReactiveGraph() @@ -372,11 +372,10 @@ def test_morphism_str_handles_unknown_type(): @pytest.mark.unit def test_morphism_parser_handles_empty_signature(): - """MorphismParser.parse() handles empty signature by returning single morphism with empty name.""" - # Empty signature should return single morphism with empty name + """MorphismParser.parse() handles empty signature by returning identity morphism.""" + # Empty signature should return identity morphism result = MorphismParser.parse("") - assert result._type == "single" - assert result._name == "" + assert result._type == "identity" @pytest.mark.unit @@ -425,7 +424,7 @@ def test_verify_universal_properties_returns_counts(): a = observable(1) b = a >> (lambda x: x + 1) c = a >> (lambda x: x * 2) - merged = (b | c) >> (lambda u, v: u + v) + merged = (b + c) >> (lambda u, v: u + v) graph = ReactiveGraph() graph.build_from_observables([merged]) # Act diff --git a/tests/unit/optimizer/optimizer/test_optimizer_costs.py b/tests/unit/optimizer/optimizer/test_optimizer_costs.py index 920a48b..6e9be5a 100644 --- a/tests/unit/optimizer/optimizer/test_optimizer_costs.py +++ b/tests/unit/optimizer/optimizer/test_optimizer_costs.py @@ -32,7 +32,7 @@ def test_optimize_materialization_sets_flags_across_graph(): # Create a small diamond to trigger decisions left = base >> (lambda x: x + 1) right = base >> (lambda x: x + 2) - out = (left | right) >> (lambda l, r: l + r) + out = (left + right) >> (lambda l, r: l + r) rg = ReactiveGraph() rg.build_from_observables([out]) diff --git a/tests/unit/test_reactive.py b/tests/unit/test_reactive.py index 76e3bb0..23c3924 100644 --- a/tests/unit/test_reactive.py +++ b/tests/unit/test_reactive.py @@ -335,7 +335,7 @@ def log_values(val1, val2): assert execution_log[0] == (5, 10) # Simulate merged value being None (edge case) - merged = obs1 | obs2 + merged = obs1 + obs2 merged._value = None # Should handle None merged values gracefully diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index 3d2700a..7c283c0 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -487,7 +487,7 @@ class MixedStore(Store): multiplier = observable(2) # Computed observable - computed_product = (base_value | multiplier).then(lambda b, m: b * m) + computed_product = (base_value + multiplier).then(lambda b, m: b * m) # Arrange store = MixedStore() @@ -748,8 +748,8 @@ def test_store_rshift_with_merged_observables(): class TestStore(Store): width = observable(10) height = observable(20) - area = (width | height) >> (lambda w, h: w * h) - perimeter = (width | height) >> (lambda w, h: 2 * (w + h)) + area = (width + height) >> (lambda w, h: w * h) + perimeter = (width + height) >> (lambda w, h: 2 * (w + h)) store = TestStore() @@ -811,12 +811,12 @@ class TestStore(Store): y = observable(10) # Computed values that use merged observables inline (like the working examples) - sum_coords = (x | y) >> (lambda a, b: a + b) - product_coords = (x | y) >> (lambda a, b: a * b) + sum_coords = (x + y) >> (lambda a, b: a + b) + product_coords = (x + y) >> (lambda a, b: a * b) # Also test with three observables z = observable(2) - total = (x | y | z) >> (lambda a, b, c: a + b + c) + total = (x + y + z) >> (lambda a, b, c: a + b + c) store = TestStore()