From bb975c5954a7b2005e1d8d230e800753d9a7745e Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 17:12:58 -0600 Subject: [PATCH 1/7] Improve documentation html --- .coveragerc | 2 - .pre-commit-config.yaml | 16 +- README.md | 107 ++- docs/README.md | 18 +- .../markdown/assets/images/banner.svg | 591 +++++++++++++ .../markdown/assets/images/icon_350x350.png | Bin 0 -> 115934 bytes .../markdown/assets}/javascripts/mathjax.js | 2 +- .../markdown/assets/stylesheets/extra.css | 755 ++++++++++++++++ docs/generation/markdown/decorators.md | 396 --------- docs/{ => generation/markdown}/index.md | 86 +- .../mathematical-foundations.md | 18 +- .../generation/markdown/reactive-decorator.md | 5 - .../markdown/{ => reference}/api.md | 21 +- .../{ => reference}/computed-observable.md | 8 +- .../{ => reference}/conditional-observable.md | 8 +- .../{ => reference}/merged-observable.md | 0 .../{ => reference}/observable-descriptors.md | 0 .../{ => reference}/observable-operators.md | 0 .../markdown/{ => reference}/observable.md | 0 .../markdown/reference/reactive-decorator.md | 299 +++++++ docs/generation/markdown/reference/store.md | 5 + docs/generation/markdown/store.md | 5 - .../markdown/{ => tutorial}/conditionals.md | 12 +- .../{ => tutorial}/derived-observables.md | 31 +- .../markdown/{ => tutorial}/observables.md | 21 +- .../markdown/{ => tutorial}/stores.md | 27 +- .../markdown/tutorial/using-reactive.md | 824 ++++++++++++++++++ docs/generation/markdown/using-reactive.md | 548 ------------ docs/generation/mkdocs.yml | 71 +- docs/generation/scripts/generate_html.py | 13 +- docs/generation/scripts/preview_html_docs.sh | 4 +- docs/stylesheets/extra.css | 131 --- scripts/deploy_docs.sh | 12 +- tests/CONVENTIONS.md | 96 +- 34 files changed, 2780 insertions(+), 1352 deletions(-) create mode 100644 docs/generation/markdown/assets/images/banner.svg create mode 100644 docs/generation/markdown/assets/images/icon_350x350.png rename docs/{ => generation/markdown/assets}/javascripts/mathjax.js (98%) create mode 100644 docs/generation/markdown/assets/stylesheets/extra.css delete mode 100644 docs/generation/markdown/decorators.md rename docs/{ => generation/markdown}/index.md (90%) rename docs/generation/markdown/{ => mathematical}/mathematical-foundations.md (97%) delete mode 100644 docs/generation/markdown/reactive-decorator.md rename docs/generation/markdown/{ => reference}/api.md (96%) rename docs/generation/markdown/{ => reference}/computed-observable.md (83%) rename docs/generation/markdown/{ => reference}/conditional-observable.md (83%) rename docs/generation/markdown/{ => reference}/merged-observable.md (100%) rename docs/generation/markdown/{ => reference}/observable-descriptors.md (100%) rename docs/generation/markdown/{ => reference}/observable-operators.md (100%) rename docs/generation/markdown/{ => reference}/observable.md (100%) create mode 100644 docs/generation/markdown/reference/reactive-decorator.md create mode 100644 docs/generation/markdown/reference/store.md delete mode 100644 docs/generation/markdown/store.md rename docs/generation/markdown/{ => tutorial}/conditionals.md (97%) rename docs/generation/markdown/{ => tutorial}/derived-observables.md (95%) rename docs/generation/markdown/{ => tutorial}/observables.md (90%) rename docs/generation/markdown/{ => tutorial}/stores.md (96%) create mode 100644 docs/generation/markdown/tutorial/using-reactive.md delete mode 100644 docs/generation/markdown/using-reactive.md mode change 100755 => 100644 docs/generation/scripts/generate_html.py delete mode 100644 docs/stylesheets/extra.css 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..d7173cc 100644 --- a/README.md +++ b/README.md @@ -199,20 +199,21 @@ Stores provide structure for related state and enable features like store-level ## The Four Reactive Operators -FynX provides four composable operators that form a complete algebra for reactive programming: +FynX provides four 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()`, `.also()`, `.negate()`): -| 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` | +| Operator | Method | Operation | Purpose | Example | +|----------|--------|-----------|---------|---------| +| `>>` | `.then()` | Transform | Apply functions to values | `price >> (lambda p: f"${p:.2f}")` | +| `\|` | `.alongside()` | Combine | Merge observables into tuples | `(first \| last) >> join` | +| `&` | `.also()` | Filter | Gate based on conditions | `file & valid & ~processing` | +| `~` | `.negate()` | Negate | Invert boolean conditions | `~is_loading` | +| | `.either()` | Logical OR | Combine boolean conditions | *(coming soon)* | 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 +241,9 @@ 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: ```python class User(Store): @@ -265,9 +266,9 @@ When any combined observable changes, downstream values recalculate automaticall > **Note:** The `|` operator will transition to `@` in a future release to support logical OR operations. -## Filtering with `&` and `~` +## Filtering with `&`, `.also()`, `~`, and `.negate()` -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 `.also()`) filters observables to emit only when [conditions](https://off-by-some.github.io/fynx/generation/markdown/conditionals/) are met. Use `~` (or `.negate()`) to invert: ```python uploaded_file = observable(None) @@ -281,52 +282,80 @@ 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 .also() method) +preview_ready_method = uploaded_file.also(is_valid_method).also(is_processing.negate()) preview_ready_operator = uploaded_file & is_valid_operator & (~is_processing) ``` 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. + ## 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 + +@reactive(error_log) +def log_errors(error): + print(f"Error: {error}") # Side effect: logging + +# GOOD: Data transformations with >> operator +doubled = count >> (lambda x: x * 2) # Pure transformation +formatted = doubled >> (lambda x: f"${x:.2f}") # Pure transformation -def print_new_value(x): - print(f"New value: {x}") +# Inline subscriptions for dynamic behavior +observable.subscribe(lambda x: print(f"New value: {x}")) -# Dedicated reaction functions -@reactive(observable) -def handle_change(value): - print(f"Changed: {value}") +# Conditional reactions using boolean operators +is_logged_in = observable(False) +has_data = observable(False) +is_loading = observable(True) -# Inline reactions -observable.subscribe(print_new_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 -# Conditional reactions using conditional observables -condition1 = observable(True) -condition2 = observable(False) +# Multiple observables via derived state +first_name = observable("Alice") +last_name = observable("Smith") -def check_conditions(): - return condition1.value and condition2.value +# Derive first, then react +full_name = (first_name | last_name) >> (lambda f, l: f"{f} {l}") -# Create conditional observable -all_conditions_met = (condition1 | condition2).then(check_conditions) +@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 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 0000000000000000000000000000000000000000..6e0b3555d06cb60d0a1e431b405e9a51f24e8b3b GIT binary patch literal 115934 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 O0000 { 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 90% rename from docs/index.md rename to docs/generation/markdown/index.md index 2979b1a..ac68663 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 @@ -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 diff --git a/docs/generation/markdown/mathematical-foundations.md b/docs/generation/markdown/mathematical/mathematical-foundations.md similarity index 97% rename from docs/generation/markdown/mathematical-foundations.md rename to docs/generation/markdown/mathematical/mathematical-foundations.md index b9cd7f8..22fd0b0 100644 --- a/docs/generation/markdown/mathematical-foundations.md +++ b/docs/generation/markdown/mathematical/mathematical-foundations.md @@ -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 ``` @@ -319,7 +319,7 @@ 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. @@ -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. @@ -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 @@ -595,4 +595,4 @@ The mathematical foundations serve three purposes: they prove compositions work 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 96% rename from docs/generation/markdown/api.md rename to docs/generation/markdown/reference/api.md index bcb8fb4..c4c1181 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 @@ -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 83% rename from docs/generation/markdown/conditional-observable.md rename to docs/generation/markdown/reference/conditional-observable.md index 88ef758..64dbb51 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 100% rename from docs/generation/markdown/merged-observable.md rename to docs/generation/markdown/reference/merged-observable.md 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..1ee2fc6 --- /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 97% rename from docs/generation/markdown/conditionals.md rename to docs/generation/markdown/tutorial/conditionals.md index 73c8e6b..5558ba1 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) @@ -402,11 +402,11 @@ 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**: 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 95% rename from docs/generation/markdown/derived-observables.md rename to docs/generation/markdown/tutorial/derived-observables.md index d08e7b6..86db015 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. @@ -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]) @@ -285,9 +286,9 @@ expensive_message_operator = (discounted_total_method | is_expensive_operator) > 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): @@ -649,14 +650,14 @@ 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. diff --git a/docs/generation/markdown/observables.md b/docs/generation/markdown/tutorial/observables.md similarity index 90% rename from docs/generation/markdown/observables.md rename to docs/generation/markdown/tutorial/observables.md index 0ed8bf3..754f46a 100644 --- a/docs/generation/markdown/observables.md +++ b/docs/generation/markdown/tutorial/observables.md @@ -202,24 +202,23 @@ 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 +* **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 96% rename from docs/generation/markdown/stores.md rename to docs/generation/markdown/tutorial/stores.md index 1e57797..1b14ffe 100644 --- a/docs/generation/markdown/stores.md +++ b/docs/generation/markdown/tutorial/stores.md @@ -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): @@ -503,6 +505,7 @@ class FormStore(Store): ``` **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..8329731 --- /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/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/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..d21bb40 100644 --- a/tests/CONVENTIONS.md +++ b/tests/CONVENTIONS.md @@ -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""" ``` @@ -196,7 +196,7 @@ def create_diamond_dependency(): path_a = source >> (lambda x: x + 5) path_b = source >> (lambda x: x * 2) 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 ``` @@ -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) - + assert combined.value == 35 # (10 + 5) + (10 * 2) - + source.set(20) assert combined.value == 65 # (20 + 5) + (20 * 2) ``` @@ -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. From 4023d61b4e892e11c3e742acda94f0f2b8a09237 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 17:29:37 -0600 Subject: [PATCH 2/7] Update | operator to use + --- README.md | 34 +++++------ docs/generation/markdown/index.md | 6 +- .../mathematical/mathematical-foundations.md | 38 ++++++------ docs/generation/markdown/reference/api.md | 16 ++--- .../reference/conditional-observable.md | 2 +- .../markdown/reference/merged-observable.md | 2 +- .../markdown/reference/reactive-decorator.md | 16 ++--- .../markdown/tutorial/derived-observables.md | 24 ++++---- .../markdown/tutorial/observables.md | 4 +- docs/generation/markdown/tutorial/stores.md | 28 ++++----- .../markdown/tutorial/using-reactive.md | 28 ++++----- docs/specs/v1.0-roadmap.md | 26 ++++---- examples/advanced_user_profile.py | 24 ++++---- examples/basics.py | 10 ++-- examples/cart_checkout.py | 2 +- examples/streamlit/todo_store.py | 2 +- examples/using_reactive_conditionals.py | 8 +-- fynx/__init__.py | 2 +- fynx/observable/__init__.py | 8 +-- fynx/observable/computed.py | 48 +++++++-------- fynx/observable/descriptors.py | 4 +- fynx/observable/merged.py | 18 +++--- fynx/observable/operations.py | 2 +- fynx/observable/operators.py | 60 +++++++++++++------ fynx/optimizer/morphism.py | 2 +- fynx/optimizer/optimizer.py | 2 +- fynx/reactive.py | 2 +- fynx/store.py | 2 +- tests/CONVENTIONS.md | 10 ++-- tests/conftest.py | 6 +- tests/integration/test_basic.py | 36 +++++------ tests/integration/test_circular_dependency.py | 18 +++--- tests/integration/test_optimizer.py | 10 ++-- .../test_reactive_system_interactions.py | 11 ++-- tests/integration/test_readme.py | 16 ++--- tests/test_factories.py | 10 ++-- tests/unit/observable/base/test_core.py | 24 ++++---- .../test_observable_notification_system.py | 10 ++-- tests/unit/observable/test_conditional.py | 6 +- tests/unit/observable/test_descriptors.py | 4 +- tests/unit/observable/test_operations.py | 2 +- tests/unit/observable/test_operators.py | 12 ++-- .../unit/optimizer/morphism/test_morphism.py | 4 +- .../optimizer/optimizer/test_optimizer.py | 11 ++-- .../optimizer/test_optimizer_costs.py | 2 +- tests/unit/test_reactive.py | 2 +- tests/unit/test_store.py | 12 ++-- 47 files changed, 320 insertions(+), 306 deletions(-) diff --git a/README.md b/README.md index d7173cc..a7cc6eb 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,11 @@ 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) def print_total(total): print(f"Cart Total: ${total:.2f}") @@ -108,7 +107,7 @@ Here's what makes FynX different: the reactive behavior doesn't just work for th 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: * **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. +* **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. The functoriality property guarantees that lifted functions preserve composition: @@ -123,7 +122,6 @@ These same categorical structures also enable FynX's automatic optimizer—compo 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). - ## Performance While the theory guarantees correctness; implementation determines speed. FynX delivers both—and the mathematics directly enables the performance. @@ -135,6 +133,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 @@ -199,12 +198,12 @@ Stores provide structure for related state and enable features like store-level ## The Four Reactive Operators -FynX provides four 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()`, `.also()`, `.negate()`): +FynX provides four 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()`, `.also()`, `.negate()`): | Operator | Method | Operation | Purpose | Example | |----------|--------|-----------|---------|---------| | `>>` | `.then()` | Transform | Apply functions to values | `price >> (lambda p: f"${p:.2f}")` | -| `\|` | `.alongside()` | Combine | Merge observables into tuples | `(first \| last) >> join` | +| `+` | `.alongside()` | Combine | Merge observables into tuples | `(first + last) >> join` | | `&` | `.also()` | Filter | Gate based on conditions | `file & valid & ~processing` | | `~` | `.negate()` | Negate | Invert boolean conditions | `~is_loading` | | | `.either()` | Logical OR | Combine boolean conditions | *(coming soon)* | @@ -241,9 +240,9 @@ 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 `|` or `.alongside()` +## Combining Observables with `+` or `.alongside()` -Use `|` (or `.alongside()`) to combine multiple observables into reactive tuples: +Use `+` (or `.alongside()`) to combine multiple observables into reactive tuples: ```python class User(Store): @@ -256,16 +255,14 @@ def join_names(first_and_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 ``` 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 `&`, `.also()`, `~`, and `.negate()` The `&` operator (or `.also()`) filters observables to emit only when [conditions](https://off-by-some.github.io/fynx/generation/markdown/conditionals/) are met. Use `~` (or `.negate()`) to invert: @@ -289,7 +286,6 @@ preview_ready_operator = uploaded_file & is_valid_operator & (~is_processing) 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. - ## Reacting to Changes React to observable changes using the [`@reactive`](https://off-by-some.github.io/fynx/generation/markdown/using-reactive/) decorator or subscriptions. @@ -297,6 +293,7 @@ React to observable changes using the [`@reactive`](https://off-by-some.github.i **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 @@ -336,7 +333,7 @@ first_name = observable("Alice") last_name = observable("Smith") # Derive first, then react -full_name = (first_name | last_name) >> (lambda f, l: f"{f} {l}") +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") @reactive(full_name) def update_greeting(name): @@ -344,6 +341,7 @@ def update_greeting(name): ``` **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): @@ -353,9 +351,7 @@ def process_data(data): process_data.unsubscribe() # Stops reacting to changes ``` -**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. - - +**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 @@ -376,7 +372,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—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/generation/markdown/index.md b/docs/generation/markdown/index.md index ac68663..a526628 100644 --- a/docs/generation/markdown/index.md +++ b/docs/generation/markdown/index.md @@ -57,7 +57,7 @@ FynX has no required dependencies and works with Python 3.9 and above. **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. +**Expressive Operators**: FynX provides intuitive operators (`+`, `>>`, `&`, `~`) that let you compose reactive logic clearly and concisely, making your data flow explicit and easy to understand. ## Understanding Reactive Programming @@ -84,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." ) @@ -146,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/mathematical-foundations.md b/docs/generation/markdown/mathematical/mathematical-foundations.md index 22fd0b0..2e874fa 100644 --- a/docs/generation/markdown/mathematical/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. @@ -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] ``` @@ -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 @@ -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 @@ -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: @@ -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. diff --git a/docs/generation/markdown/reference/api.md b/docs/generation/markdown/reference/api.md index c4c1181..1e29c07 100644 --- a/docs/generation/markdown/reference/api.md +++ b/docs/generation/markdown/reference/api.md @@ -31,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()`, `.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. ### Stores: Organizing State @@ -87,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 @@ -117,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) @@ -149,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 ) diff --git a/docs/generation/markdown/reference/conditional-observable.md b/docs/generation/markdown/reference/conditional-observable.md index 64dbb51..1f229d8 100644 --- a/docs/generation/markdown/reference/conditional-observable.md +++ b/docs/generation/markdown/reference/conditional-observable.md @@ -62,5 +62,5 @@ count.set(4) # Prints: Positive even: 4 * **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 `>>` +* **Composable**: Can be combined with other observables using `&`, `+`, and `>>` * **Efficient**: Conditions are only evaluated when source values change diff --git a/docs/generation/markdown/reference/merged-observable.md b/docs/generation/markdown/reference/merged-observable.md index 379f30e..f86a2c0 100644 --- a/docs/generation/markdown/reference/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/reference/reactive-decorator.md b/docs/generation/markdown/reference/reactive-decorator.md index 1ee2fc6..075cb8d 100644 --- a/docs/generation/markdown/reference/reactive-decorator.md +++ b/docs/generation/markdown/reference/reactive-decorator.md @@ -84,7 +84,7 @@ 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) +@reactive(is_logged_in & has_data & ~is_loading + should_sync) def sync_to_server(should_run): if should_run: perform_sync() @@ -93,7 +93,7 @@ def sync_to_server(should_run): The operators work as you'd expect: * `&` is logical AND -* `|` is logical OR +* `+` 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. @@ -133,7 +133,7 @@ name = observable("Alice") age = observable(30) # Derive a combined observable first -full_name = (name | age) >> (lambda n, a: f"{n} ({a} years old)") +full_name = (name + age) >> (lambda n, a: f"{n} ({a} years old)") # Then react to changes in the derivation @reactive(full_name) @@ -144,13 +144,13 @@ 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. +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`. +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. @@ -174,7 +174,7 @@ class OrderCore(Store): 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) + total = (subtotal + tax) >> (lambda s, t: s + t) # ===== REACTIVE SHELL (Impure) ===== @reactive(OrderCore.can_checkout) @@ -274,7 +274,7 @@ class UserStore(Store): age = observable(30) is_active = observable(True) - user_summary = (name | age) >> (lambda n, a: f"{n}, {a}") + user_summary = (name + age) >> (lambda n, a: f"{n}, {a}") should_display = is_active & (age >> (lambda a: a >= 18)) @reactive(UserStore.user_summary) @@ -294,6 +294,6 @@ The store becomes your functional core. The reactions watching it become your sh *** -**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. +**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/tutorial/derived-observables.md b/docs/generation/markdown/tutorial/derived-observables.md index 86db015..4753660 100644 --- a/docs/generation/markdown/tutorial/derived-observables.md +++ b/docs/generation/markdown/tutorial/derived-observables.md @@ -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): @@ -172,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 @@ -270,16 +270,16 @@ 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 @@ -369,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:** @@ -659,6 +659,6 @@ This declarative approach eliminates entire categories of bugs: * **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/tutorial/observables.md b/docs/generation/markdown/tutorial/observables.md index 754f46a..b8cacb9 100644 --- a/docs/generation/markdown/tutorial/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}")) @@ -214,7 +214,7 @@ Observables add overhead compared to plain variables. For simple scripts or one- 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 +* **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 diff --git a/docs/generation/markdown/tutorial/stores.md b/docs/generation/markdown/tutorial/stores.md index 1b14ffe..46afa89 100644 --- a/docs/generation/markdown/tutorial/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", @@ -501,7 +501,7 @@ 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:** @@ -661,7 +661,7 @@ 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 +* **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 diff --git a/docs/generation/markdown/tutorial/using-reactive.md b/docs/generation/markdown/tutorial/using-reactive.md index 8329731..f8d2519 100644 --- a/docs/generation/markdown/tutorial/using-reactive.md +++ b/docs/generation/markdown/tutorial/using-reactive.md @@ -22,7 +22,7 @@ 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) +(first_name + last_name).subscribe(update_display_name) # Later... did you remember to unsubscribe? count.unsubscribe(update_ui) @@ -152,7 +152,7 @@ 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) +@reactive(is_logged_in & has_data & ~is_loading + should_sync) def sync_to_server(should_run): if should_run: perform_sync() @@ -161,7 +161,7 @@ def sync_to_server(should_run): The operators work exactly as you'd expect: * `&` is logical AND -* `|` is logical OR (when used with observables on both sides) +* `+` 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. @@ -212,14 +212,14 @@ The second version is clearer about *when* the sync happens—the condition is p ## 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: +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}") +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") # Then react to changes in the derivation @reactive(full_name) @@ -232,7 +232,7 @@ 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. +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. @@ -242,7 +242,7 @@ class CartStore(Store): tax_rate = observable(0.08) # Derive the meaningful state -total = (CartStore.items | CartStore.tax_rate) >> ( +total = (CartStore.items + CartStore.tax_rate) >> ( lambda items, rate: sum(item['price'] * item['qty'] for item in items) * (1 + rate) ) @@ -398,7 +398,7 @@ password_valid = FormStore.password >> ( lambda p: len(p) >= 8 ) -passwords_match = (FormStore.password | FormStore.confirm_password) >> ( +passwords_match = (FormStore.password + FormStore.confirm_password) >> ( lambda pwd, confirm: pwd == confirm and pwd != "" ) @@ -447,7 +447,7 @@ Notice how we use the `&` operator to create `form_valid`—it only becomes true 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`. +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. @@ -469,7 +469,7 @@ class OrderCore(Store): 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) + total = (subtotal + tax) >> (lambda s, t: s + t) # ===== REACTIVE SHELL (Impure) ===== @reactive(OrderCore.can_checkout) @@ -541,7 +541,7 @@ def update_editor_theme(is_dark): **❌ AVOID @reactive for:** -**Deriving State** (use `>>`, `|`, `&`, `~` instead) +**Deriving State** (use `>>`, `+`, `&`, `~` instead) ```python # BAD: Using @reactive for transformation @@ -562,7 +562,7 @@ 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}") +full_name = (first_name + last_name) >> (lambda f, l: f"{f} {l}") ``` **Business Logic** (keep logic in pure transformations) @@ -812,7 +812,7 @@ The `@reactive` decorator transforms functions into automatic reactions that run * **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`) +* **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 @@ -821,4 +821,4 @@ The `@reactive` decorator transforms functions into automatic reactions that run 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. +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/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/examples/advanced_user_profile.py b/examples/advanced_user_profile.py index 5aaf002..3fff268 100644 --- a/examples/advanced_user_profile.py +++ b/examples/advanced_user_profile.py @@ -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) @@ -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..01a9de8 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" @@ -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..0a1b2a2 100644 --- a/examples/using_reactive_conditionals.py +++ b/examples/using_reactive_conditionals.py @@ -134,7 +134,7 @@ 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 ) @@ -277,7 +277,7 @@ 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 ) @@ -357,8 +357,8 @@ 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)) ) 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/descriptors.py b/fynx/observable/descriptors.py index ecc0665..ddfea6a 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: diff --git a/fynx/observable/merged.py b/fynx/observable/merged.py index 1c06c9b..42861f8 100644 --- a/fynx/observable/merged.py +++ b/fynx/observable/merged.py @@ -12,14 +12,14 @@ - **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) @@ -62,7 +62,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 @@ -133,7 +133,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) @@ -229,12 +229,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 +265,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 +329,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..6c6bc1e 100644 --- a/fynx/observable/operations.py +++ b/fynx/observable/operations.py @@ -8,7 +8,7 @@ The operations provide a fluent, readable API for reactive programming: - `then(func)` - Transform values (equivalent to `>>` operator) -- `alongside(other)` - Merge observables (equivalent to `|` operator) +- `alongside(other)` - Merge observables (equivalent to `+` operator) - `also(condition)` - Compose boolean conditions with AND (equivalent to `&` operator) - `negate()` - Boolean negation (equivalent to `~` operator) - `either(other)` - OR logic for boolean conditions diff --git a/fynx/observable/operators.py b/fynx/observable/operators.py index 05fe49f..dd58feb 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. @@ -287,7 +302,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 +354,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 +413,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 +439,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 +452,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..6daa228 100644 --- a/fynx/optimizer/optimizer.py +++ b/fynx/optimizer/optimizer.py @@ -2091,7 +2091,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/tests/CONVENTIONS.md b/tests/CONVENTIONS.md index d21bb40..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. @@ -195,7 +195,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 ``` @@ -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): @@ -601,7 +601,7 @@ def test_diamond_dependency_resolves_correctly(): 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) @@ -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 = [ 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..487e40f 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) @@ -593,7 +593,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..dbd6a37 100644 --- a/tests/unit/observable/test_conditional.py +++ b/tests/unit/observable/test_conditional.py @@ -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 @@ -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 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..e8ba5b8 100644 --- a/tests/unit/observable/test_operators.py +++ b/tests/unit/observable/test_operators.py @@ -9,12 +9,12 @@ @pytest.mark.observable @pytest.mark.operators def test_or_operator_merges_observables_into_tuple(): - """Merging with | produces a tuple of current values.""" + """Merging with + produces a tuple of current values.""" # Arrange a = Observable("a", 2) b = Observable("b", 3) # Act - merged = a | b + merged = a + b # Assert assert merged.value == (2, 3) @@ -27,7 +27,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 +145,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 +162,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 +347,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() From bfa3c29ad5da1a1ffc87ab3f29e23ab1eb7d15bc Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 17:38:49 -0600 Subject: [PATCH 3/7] Make MergedObservable a computed observable --- README.md | 16 ++++--- fynx/observable/merged.py | 62 ++++++++++++------------- fynx/optimizer/optimizer.py | 24 +++++----- tests/unit/observable/base/test_core.py | 11 +++-- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index a7cc6eb..d50f3b3 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ This breadth isn't accidental. The universal properties underlying FynX apply to ## 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: @@ -116,7 +116,7 @@ $$ \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. +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. @@ -203,7 +203,7 @@ FynX provides four composable operators that form a complete algebra for reactiv | Operator | Method | Operation | Purpose | Example | |----------|--------|-----------|---------|---------| | `>>` | `.then()` | Transform | Apply functions to values | `price >> (lambda p: f"${p:.2f}")` | -| `+` | `.alongside()` | Combine | Merge observables into tuples | `(first + last) >> join` | +| `+` | `.alongside()` | Combine | Merge observables into read-only tuples | `(first + last) >> join` | | `&` | `.also()` | Filter | Gate based on conditions | `file & valid & ~processing` | | `~` | `.negate()` | Negate | Invert boolean conditions | `~is_loading` | | | `.either()` | Logical OR | Combine boolean conditions | *(coming soon)* | @@ -242,7 +242,8 @@ Each transformation creates a new observable that recalculates when its source c ## Combining Observables with `+` or `.alongside()` -Use `+` (or `.alongside()`) 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,8 +251,7 @@ 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() @@ -259,6 +259,10 @@ 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 + +# 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. diff --git a/fynx/observable/merged.py b/fynx/observable/merged.py index 42861f8..bf7001a 100644 --- a/fynx/observable/merged.py +++ b/fynx/observable/merged.py @@ -3,10 +3,12 @@ ================================================ 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 @@ -24,6 +26,9 @@ 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: @@ -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) @@ -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. diff --git a/fynx/optimizer/optimizer.py b/fynx/optimizer/optimizer.py index 6daa228..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 diff --git a/tests/unit/observable/base/test_core.py b/tests/unit/observable/base/test_core.py index 487e40f..7849665 100644 --- a/tests/unit/observable/base/test_core.py +++ b/tests/unit/observable/base/test_core.py @@ -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 From 9799d931a4ff41bbf1b542afe734930822444210 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 19:05:07 -0600 Subject: [PATCH 4/7] Fix :bug: where conditional pullbacks did not work when used with observables, unwrapped type --- README.md | 24 +- .../markdown/tutorial/conditionals.md | 58 ++++ .../markdown/tutorial/observables.md | 1 + examples/advanced_user_profile.py | 10 +- examples/basics.py | 2 +- examples/using_reactive_conditionals.py | 308 ++++++++++++------ fynx/observable/conditional.py | 19 +- fynx/observable/descriptors.py | 10 + fynx/observable/operators.py | 18 + tests/unit/observable/test_conditional.py | 56 +++- tests/unit/observable/test_operators.py | 88 ++++- 11 files changed, 470 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index d50f3b3..d5d266f 100644 --- a/README.md +++ b/README.md @@ -194,19 +194,19 @@ 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. +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. -## The Four Reactive Operators +## The Five Reactive Operators -FynX provides four 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()`, `.also()`, `.negate()`): +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()`, `.also()`, `.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` | | `&` | `.also()` | Filter | Gate based on conditions | `file & valid & ~processing` | +| `\|` | `.either()` | Logical OR | Combine boolean conditions | `is_error \| is_warning` | | `~` | `.negate()` | Negate | Invert boolean conditions | `~is_loading` | -| | `.either()` | Logical OR | Combine boolean conditions | *(coming soon)* | 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. @@ -267,13 +267,15 @@ merged = User.first_name + User.last_name When any combined observable changes, downstream values recalculate automatically. This operator constructs categorical products, ensuring combination remains symmetric and associative regardless of nesting. -## Filtering with `&`, `.also()`, `~`, and `.negate()` +## Filtering with `&`, `.also()`, `~`, `.negate()`, `|`, and `.either()` -The `&` operator (or `.also()`) filters observables to emit only when [conditions](https://off-by-some.github.io/fynx/generation/markdown/conditionals/) are met. Use `~` (or `.negate()`) to invert: +The `&` operator (or `.also()`) 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): @@ -286,9 +288,13 @@ is_valid_operator = uploaded_file >> is_valid_file # Filter using & operator (or .also() method) preview_ready_method = uploaded_file.also(is_valid_method).also(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 @@ -355,7 +361,7 @@ def process_data(data): process_data.unsubscribe() # Stops reacting to changes ``` -**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. +**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 @@ -376,7 +382,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/generation/markdown/tutorial/conditionals.md b/docs/generation/markdown/tutorial/conditionals.md index 5558ba1..e9299c5 100644 --- a/docs/generation/markdown/tutorial/conditionals.md +++ b/docs/generation/markdown/tutorial/conditionals.md @@ -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: @@ -403,6 +460,7 @@ 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**: Create logical OR conditions between boolean observables * **`~` operator**: Invert boolean conditions * **Performance**: Skip unnecessary computations * **Clarity**: Separate filtering logic from reaction logic diff --git a/docs/generation/markdown/tutorial/observables.md b/docs/generation/markdown/tutorial/observables.md index b8cacb9..3586376 100644 --- a/docs/generation/markdown/tutorial/observables.md +++ b/docs/generation/markdown/tutorial/observables.md @@ -216,6 +216,7 @@ Observables are more than containers—they're nodes in a reactive graph. But st * **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 diff --git a/examples/advanced_user_profile.py b/examples/advanced_user_profile.py index 3fff268..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 @@ -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!") diff --git a/examples/basics.py b/examples/basics.py index 01a9de8..d2f3a90 100644 --- a/examples/basics.py +++ b/examples/basics.py @@ -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!") diff --git a/examples/using_reactive_conditionals.py b/examples/using_reactive_conditionals.py index 0a1b2a2..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 ===") @@ -138,17 +191,20 @@ class FormStore(Store): 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. @@ -281,9 +357,10 @@ class ShoppingCartStore(Store): 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 ===") @@ -363,21 +444,30 @@ class UserActivityStore(Store): 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/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 ddfea6a..b5f9639 100644 --- a/fynx/observable/descriptors.py +++ b/fynx/observable/descriptors.py @@ -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/operators.py b/fynx/observable/operators.py index dd58feb..aabd896 100644 --- a/fynx/observable/operators.py +++ b/fynx/observable/operators.py @@ -252,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: """ diff --git a/tests/unit/observable/test_conditional.py b/tests/unit/observable/test_conditional.py index dbd6a37..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) @@ -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) @@ -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_operators.py b/tests/unit/observable/test_operators.py index e8ba5b8..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 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 merged.value == (2, 3) + 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 From e15289f18a67095d0a82db397936fa2e0633e3bd Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 19:25:52 -0600 Subject: [PATCH 5/7] Rename .also -> .requiring --- README.md | 47 +++++++++++++---------- docs/generation/markdown/index.md | 2 +- docs/generation/markdown/reference/api.md | 2 +- fynx/observable/operations.py | 6 +-- fynx/observable/operators.py | 2 +- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d5d266f..927546e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ def calculate_total(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) >> calculate_total # Equivalent! def print_total(total): print(f"Cart Total: ${total:.2f}") @@ -100,15 +101,32 @@ 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 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. + +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. -* **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. The functoriality property guarantees that lifted functions preserve composition: @@ -196,19 +214,6 @@ 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 five fundamental operators. -## 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()`, `.also()`, `.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` | -| `&` | `.also()` | Filter | Gate 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. ## Transforming Data with `>>` or `.then()` @@ -267,9 +272,9 @@ merged = User.first_name + User.last_name When any combined observable changes, downstream values recalculate automatically. This operator constructs categorical products, ensuring combination remains symmetric and associative regardless of nesting. -## Filtering with `&`, `.also()`, `~`, `.negate()`, `|`, and `.either()` +## Filtering with `&`, `.requiring()`, `~`, `.negate()`, `|`, and `.either()` -The `&` operator (or `.also()`) 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: +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) @@ -285,8 +290,8 @@ 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 (or .also() method) -preview_ready_method = uploaded_file.also(is_valid_method).also(is_processing.negate()) +# 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) diff --git a/docs/generation/markdown/index.md b/docs/generation/markdown/index.md index a526628..c24edff 100644 --- a/docs/generation/markdown/index.md +++ b/docs/generation/markdown/index.md @@ -57,7 +57,7 @@ FynX has no required dependencies and works with Python 3.9 and above. **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. +**Expressive Operators**: FynX provides intuitive operators (`+`, `>>`, `&`, `~`, `|`) that let you compose reactive logic clearly and concisely, making your data flow explicit and easy to understand. ## Understanding Reactive Programming diff --git a/docs/generation/markdown/reference/api.md b/docs/generation/markdown/reference/api.md index 1e29c07..f4b96eb 100644 --- a/docs/generation/markdown/reference/api.md +++ b/docs/generation/markdown/reference/api.md @@ -39,7 +39,7 @@ Observables are containers for values that change over time. Unlike regular vari **[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 diff --git a/fynx/observable/operations.py b/fynx/observable/operations.py index 6c6bc1e..e6be116 100644 --- a/fynx/observable/operations.py +++ b/fynx/observable/operations.py @@ -9,7 +9,7 @@ - `then(func)` - Transform values (equivalent to `>>` operator) - `alongside(other)` - Merge observables (equivalent to `+` operator) -- `also(condition)` - Compose boolean conditions with AND (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 aabd896..cce8adf 100644 --- a/fynx/observable/operators.py +++ b/fynx/observable/operators.py @@ -238,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]": """ From 027ac1d75613a302960b226b7da2259d37da72a0 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 19:28:23 -0600 Subject: [PATCH 6/7] Update README.md --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 927546e..9198224 100644 --- a/README.md +++ b/README.md @@ -129,16 +129,15 @@ FynX satisfies specific universal properties from category theory, guaranteeing 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 $$ -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. +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. -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. +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 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). +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/test_readme.py), naturally.) ## Performance From 461ec3b67cc255d67cf1d43eec1e7f39adf754e3 Mon Sep 17 00:00:00 2001 From: Cassidy Bridges Date: Mon, 20 Oct 2025 19:30:07 -0600 Subject: [PATCH 7/7] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9198224..be617b7 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ In practice, this means complex reactive systems composed from simple parts beha 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/test_readme.py), naturally.) +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