├── .github └── workflows │ ├── docs.yml │ ├── publish.yml │ ├── ruff.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── api │ ├── core.md │ ├── display.md │ ├── plugins.md │ └── types.md ├── changes.md ├── index.md ├── limitations.md ├── overrides │ └── main.html ├── plugins.md └── usage.md ├── examples └── plugins │ └── access_tracker.py ├── mkdocs.yml ├── pyproject.toml ├── src └── signified │ ├── __init__.py │ ├── core.py │ ├── display.py │ ├── ops.py │ ├── plugins.py │ ├── py.typed │ ├── types.py │ └── utils.py └── tests ├── conftest.py ├── test_computed.py ├── test_reactive_methods.py ├── test_signals.py ├── test_utils.py └── type_inference.py /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | build: 18 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.12" 26 | cache: 'pip' 27 | cache-dependency-path: '**/pyproject.toml' 28 | - run: pip install mkdocs-material mkdocstrings[python] mkdocs-material-extensions 29 | - name: Setup Pages 30 | uses: actions/configure-pages@v4 31 | - name: Build with MkDocs 32 | run: mkdocs build 33 | - name: Upload artifact 34 | uses: actions/upload-pages-artifact@v3 35 | with: 36 | path: ./site 37 | 38 | deploy: 39 | environment: 40 | name: github-pages 41 | url: ${{ steps.deployment.outputs.page_url }} 42 | runs-on: ubuntu-latest 43 | needs: build 44 | steps: 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | release-build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | 21 | - name: build release distributions 22 | run: | 23 | python -m pip install build 24 | python -m build 25 | 26 | - name: upload windows dists 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: release-dists 30 | path: dist/ 31 | 32 | pypi-publish: 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: pypi 36 | url: https://pypi.org/p/signified 37 | needs: 38 | - release-build 39 | permissions: 40 | id-token: write 41 | 42 | steps: 43 | - name: Retrieve release distributions 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: release-dists 47 | path: dist/ 48 | 49 | - name: Publish release distributions to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: ruff 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Install Python 9 | uses: actions/setup-python@v5 10 | with: 11 | python-version: "3.12" 12 | - name: Install dependencies 13 | run: | 14 | python -m pip install --upgrade pip 15 | pip install ruff 16 | # Update output format to enable automatic inline annotations. 17 | - name: Run Ruff 18 | run: ruff check --output-format=github . 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["pypy3.9", "pypy3.10", "3.9", "3.10", "3.11", "3.12"] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install . 22 | - name: Test with pytest 23 | run: | 24 | pip install pytest pytest-cov 25 | pytest tests/ --doctest-modules --pyargs signified --cov=signified --cov-report=xml --junitxml=junit/test-results.xml 26 | - name: Run Pyright 27 | uses: jakebailey/pyright-action@v2 28 | with: 29 | pylance-version: latest-release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | junit/ 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Doug Mercer 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signified 2 | 3 | [![PyPI - Downloads](https://img.shields.io/pypi/dw/signified)](https://pypi.org/project/signified/) 4 | [![PyPI - Version](https://img.shields.io/pypi/v/signified)](https://pypi.org/project/signified/) 5 | [![Tests Status](https://github.com/dougmercer/signified/actions/workflows/test.yml/badge.svg)](https://github.com/dougmercer/signified/actions/workflows/test.yml?query=branch%3Amain) 6 | 7 | --- 8 | 9 | **Documentation**: [https://dougmercer.github.io/signified](https://dougmercer.github.io/signified) 10 | 11 | **Source Code**: [https://github.com/dougmercer/signified](https://github.com/dougmercer/signified) 12 | 13 | --- 14 | 15 | A Python library for reactive programming (with kind-of working type narrowing). 16 | 17 | ## Getting started 18 | 19 | ```console 20 | pip install signified 21 | ``` 22 | 23 | ## Why care? 24 | 25 | `signified` is a reactive programming library that implements two primary data structures: `Signal` and `Computed`. 26 | 27 | Both of these objects implement the *Observer* and *Observable* design patterns. This means that they can notify 28 | other *Observers* if they change, and they can subscribe to be notified if another *Observable* changes. 29 | 30 | This allows us to create a network of computation, where one value being modified can trigger other objects to update. 31 | 32 | This allows us to write more declarative code, like, 33 | 34 | ```python 35 | x = Signal(3) 36 | x_squared = x ** 2 # currently equal to 9 37 | x.value = 10 # Will immediately notify x_squared, whose value will become 100. 38 | ``` 39 | 40 | Here, `x_squared` became a reactive expression (more specifically, a `Computed` object) whose value is always equal to `x ** 2`. Neat! 41 | 42 | `signified`'s `Signal` object effectively gives us a container which stores a value, and `Computed` gives us a container to store the current value of a function. In the above example, we generated the Computed object on-the-fly using overloaded Python operators like `**`, but we could have just as easily done, 43 | 44 | ```python 45 | from signified import computed 46 | 47 | @computed 48 | def power(x, n): 49 | return x**n 50 | 51 | x_squared = power(x, 2) # equivalent to the above 52 | ``` 53 | 54 | Together, these data structures allow us to implement a wide variety of capabilities. In particular, I wrote this library to make my to-be-released animation library easier to maintain and more fun to work with. 55 | 56 | ## ... what do you mean by "kind of working type narrowing"? 57 | 58 | Other reactive Python libraries don't really attempt to implement type hints (e.g., [param](https://param.holoviz.org/)). 59 | 60 | ``signified`` is type hinted and supports type narrowing even for nested reactive values. 61 | 62 | ```python 63 | from signified import Signal 64 | 65 | a = Signal(1.0) 66 | b = Signal(Signal(Signal(2))) 67 | reveal_type(a + b) # Computed[float | int] 68 | ``` 69 | 70 | Unfortunately, for the time being, our type hints only work with ``pyright``. 71 | 72 | ## Ready to learn more? 73 | 74 | Checkout the docs at [https://dougmercer.github.io/signified](https://dougmercer.github.io/signified) or watch [my YouTube video about the library](https://youtu.be/nkuXqx-6Xwc). 75 | -------------------------------------------------------------------------------- /docs/api/core.md: -------------------------------------------------------------------------------- 1 | # Core 2 | 3 | ::: signified.core 4 | ::: signified.utils 5 | ::: signified.ops 6 | -------------------------------------------------------------------------------- /docs/api/display.md: -------------------------------------------------------------------------------- 1 | # Display 2 | 3 | ::: signified.display 4 | -------------------------------------------------------------------------------- /docs/api/plugins.md: -------------------------------------------------------------------------------- 1 | # Display 2 | 3 | ::: signified.plugins 4 | -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | ::: signified.types 4 | -------------------------------------------------------------------------------- /docs/changes.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.2.0 4 | 5 | New Features 6 | 7 | * Add a plugin system 8 | 9 | Performance 10 | 11 | * Use slots to save memory 12 | 13 | Bugfixes 14 | 15 | * Fix bug in unobserve method, replacing subscribe with unsubscribe 16 | * Add a deep_unref function to handle some nested signal edge cases 17 | 18 | Type Inference 19 | 20 | * Improve reactive_method's ability to properly infer types 21 | 22 | CI/CD 23 | 24 | * Make ruff actually enforce isort-like imports 25 | 26 | Docs 27 | 28 | * Improve Usage section of the docs 29 | * Add a Limitations page to the docs 30 | * Add a plugins page to the docs 31 | 32 | ## 0.1.5 33 | 34 | Features 35 | 36 | * Added ``__setitem__`` and ``__setattr`` methods for generating reactive values. 37 | 38 | Docs 39 | 40 | * Added examples to docstrings (in doctest format). 41 | 42 | Bug Fixes 43 | 44 | * Under several conditions, Reactive values generated from ``__call__`` and ``__getitem__`` weren't updating when an upstream observable was updated. 45 | 46 | Typing 47 | 48 | * Improve type inference for ``__call__`` generated reactive values. 49 | 50 | ## 0.1.4 51 | Minor changes to packaging and documentation 52 | 53 | ## 0.1.1 54 | Initial release. 55 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/limitations.md: -------------------------------------------------------------------------------- 1 | # Known Limitations 2 | 3 | ## Type Inference 4 | 5 | This library's type hints do not work with `mypy`, but they do work relatively well with `pyright`. 6 | 7 | ## Methods on Mutable Collection 8 | 9 | When working with collections like `list`s, methods that mutate the underlying object don't trigger `signified` to notify observers: 10 | 11 | ```python 12 | from signified import Signal 13 | 14 | # This won't work as expected 15 | numbers = Signal([1, 2, 3]) 16 | sum_numbers = computed(sum)(numbers) 17 | print(sum_numbers.value) # 6 18 | 19 | numbers.value.append(4) # Mutation doesn't trigger update 20 | print(sum_numbers.value) # Still 6, not 10 as expected 21 | 22 | # Instead, do one of these: 23 | # 1. Assign a new list 24 | numbers.value = [1, 2, 3, 4] 25 | print(sum_numbers.value) # 10 26 | 27 | # 2. Create a new list with the existing values 28 | numbers.value = numbers.value + [4] 29 | print(sum_numbers.value) # 10 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 |
5 |
6 | 9 |
10 |
11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Writing Plugins 2 | 3 | Signified provides a plugin system built on top of [pluggy](https://pluggy.readthedocs.io/) that allows you to extend and customize its behavior. This guide explains the basics of creating plugins for signified. 4 | 5 | ## Plugin Hooks 6 | 7 | The plugin system provides hooks for key events in a reactive value's lifecycle: 8 | 9 | - `read`: Called whenever a reactive value's current value is accessed 10 | - `created`: Called when a new reactive value is instantiated 11 | - `updated`: Called when a reactive value's content changes 12 | - `named`: Called when a reactive value is given a name 13 | 14 | These hooks allow plugins to observe and respond to the complete lifecycle of reactive values. 15 | 16 | Additional hooks may be added in the future. If you have a good idea for a plugin that would benefit from additional hooks, please let me know! 17 | 18 | ## Creating a Plugin 19 | 20 | To create a plugin: 21 | 22 | 1. Create a class that will contain your hook implementations 23 | 2. Implement any desired hooks using the `@hookimpl` decorator (available from `signified.plugin`) 24 | 3. Register your plugin with the global plugin manager `pm` (also available in `signified.plugin`) 25 | 26 | Here's a minimal example: 27 | 28 | ```python 29 | from signified.plugin import hookimpl, pm 30 | from signified import ReactiveValue 31 | from typing import Any 32 | 33 | class MyPlugin: 34 | @hookimpl 35 | def created(self, value: ReactiveValue[Any]) -> None: 36 | # Do something when a reactive value is created 37 | pass 38 | 39 | # Register the plugin 40 | plugin = MyPlugin() 41 | pm.register(plugin) 42 | ``` 43 | 44 | ## Plugin Management 45 | 46 | The library maintains a global plugin manager instance in `signified.plugin.pm`. Plugins can be registered and unregistered at runtime: 47 | 48 | ```python 49 | from signified.plugin import pm 50 | 51 | # Register a plugin 52 | pm.register(my_plugin) 53 | 54 | # Remove a plugin 55 | pm.unregister(my_plugin) 56 | ``` 57 | 58 | ## More complex example 59 | 60 | I created a [simple logging plugin](https://github.com/dougmercer/signified_logging). 61 | 62 | ```python 63 | from __future__ import annotations 64 | 65 | import logging 66 | from typing import Any 67 | 68 | from signified import Variable 69 | from signified.plugin import hookimpl, pm 70 | 71 | 72 | class ReactiveLogger: 73 | """A logger plugin for tracking reactive value lifecycle.""" 74 | 75 | def __init__(self, logger: Any | None = None): 76 | """Initialize with optional logger, defaulting to standard logging.""" 77 | if logger is None: 78 | _logger = logging.getLogger(__name__) 79 | handler = logging.StreamHandler() 80 | formatter = logging.Formatter("%(message)s") 81 | handler.setFormatter(formatter) 82 | _logger.addHandler(handler) 83 | _logger.setLevel(logging.INFO) 84 | else: 85 | _logger = logger 86 | self.logger = _logger 87 | 88 | @hookimpl 89 | def created(self, value: Variable[Any, Any]) -> None: 90 | """Log when a reactive value is created. 91 | 92 | Args: 93 | value: The created reactive value 94 | """ 95 | self.logger.info(f"Created {value:d}") 96 | 97 | @hookimpl 98 | def updated(self, value: Variable[Any, Any]) -> None: 99 | """Log when a reactive value is updated. 100 | 101 | Args: 102 | value: The updated reactive value 103 | """ 104 | self.logger.info(f"Updated {value:n} to {value.value}") 105 | 106 | @hookimpl 107 | def named(self, value: Variable[Any, Any]) -> None: 108 | """Log when a reactive value is named. 109 | 110 | Args: 111 | value: The reactive value that was assigned a name. 112 | """ 113 | self.logger.info(f"Named {type(value).__name__}(id={id(value)}) as {value:n}") 114 | 115 | DEFAULT_LOGGING_PLUGIN = ReactiveLogger() 116 | pm.register(DEFAULT_LOGGING_PLUGIN) 117 | ``` 118 | 119 | Here, we implement logging behavior for the `created`, `updated`, and `named` hooks. 120 | 121 | Finally, at the end, we create the plugin and register it to the plugin manager. 122 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | Signified is a reactive programming library that helps you create and manage values that automatically update when their dependencies change. This guide will walk you through common usage patterns and features. 4 | 5 | ## Basic Concepts 6 | 7 | ### Signals 8 | 9 | A `Signal` is a container for a mutable reactive value. When you change a signal's value, any computations that depend on it will automatically update. 10 | 11 | ```python 12 | from signified import Signal 13 | 14 | name = Signal("Alice") 15 | greeting = "Hello, " + name 16 | 17 | print(greeting) # "Hello, Alice" 18 | name.value = "Bob" 19 | print(greeting) # "Hello, Bob" 20 | ``` 21 | 22 | ### Computed Values 23 | 24 | Computed values are derived from other reactive values. They can be constructed implicitly using overloaded Python operators or explicitly using the `computed` decorator. 25 | 26 | ```python 27 | from signified import Signal 28 | 29 | a = Signal(3) 30 | b = Signal(4) 31 | 32 | # c is a Computed object that will automatically update when a or b are updated 33 | c = (a ** 2 + b ** 2) ** 0.5 34 | 35 | print(c) # 5 36 | 37 | a.value = 5 38 | b.value = 12 39 | 40 | print(c) # 13 41 | ``` 42 | 43 | ```python 44 | from signified import Signal, computed 45 | 46 | numbers = Signal([1, 2, 3, 4, 5]) 47 | 48 | @computed 49 | def stats(nums): 50 | return { 51 | 'sum': sum(nums), 52 | 'mean': sum(nums) / len(nums), 53 | 'min': min(nums), 54 | 'max': max(nums) 55 | } 56 | 57 | result = stats(numbers) 58 | print(result) # {'sum': 15, 'mean': 3.0, 'min': 1, 'max': 5} 59 | 60 | numbers.value = [2, 4, 6, 8, 10] 61 | print(result) # {'sum': 30, 'mean': 6.0, 'min': 2, 'max': 10} 62 | ``` 63 | 64 | ## Working with Data 65 | 66 | ### Collections (Lists, Dicts, etc.) 67 | 68 | Signified handles collections like lists and dictionaries **somewhat well**, but there are currently [some rough edges](limitations.md). 69 | 70 | ```python 71 | from signified import Signal, computed 72 | 73 | # Working with lists 74 | numbers = Signal([1, 2, 3, 4, 5]) 75 | doubled = computed(lambda x: [n * 2 for n in x])(numbers) 76 | 77 | print(doubled) # [2, 4, 6, 8, 10] 78 | numbers.value = [5, 6, 7] 79 | print(doubled) # [10, 12, 14] 80 | 81 | # Modifying lists 82 | numbers[0] = 10 # Notifies observers 83 | print(numbers) # [10, 6, 7] 84 | ``` 85 | 86 | ```python 87 | from signified import Signal, computed 88 | 89 | # Working with dictionaries 90 | config = Signal({"theme": "dark", "fontSize": 14}) 91 | theme = config["theme"] 92 | font_size = config["fontSize"] 93 | 94 | print(theme) # "dark" 95 | print(font_size) # 14 96 | 97 | config.value = {"theme": "light", "fontSize": 16} 98 | print(theme) # "light" 99 | print(font_size) # 16 100 | ``` 101 | 102 | ### NumPy 103 | 104 | Signified integrates well with NumPy arrays: 105 | 106 | ```python 107 | from signified import Signal 108 | import numpy as np 109 | 110 | matrix = Signal(np.array([[1, 2], [3, 4]])) 111 | vector = Signal(np.array([1, 1])) 112 | 113 | result = matrix @ vector # Matrix multiplication 114 | 115 | print(result) # array([3, 7]) 116 | 117 | matrix.value = np.array([[2, 2], [4, 4]]) 118 | print(result) # array([4, 8]) 119 | ``` 120 | 121 | ## Other Topics 122 | 123 | ### Conditional Logic 124 | 125 | Use the `where()` method for conditional computations: 126 | 127 | ```python 128 | from signified import Signal 129 | 130 | username = Signal(None) 131 | is_logged_in = username.is_not(None) 132 | 133 | message = is_logged_in.where(f"Welcome back, {username}!", "Please log in") 134 | 135 | print(message) # "Please log in" 136 | username.value = "admin" 137 | print(message) # "Welcome back, admin!" 138 | ``` 139 | 140 | ### Reactive Attribute Access and Method Calls 141 | 142 | Signified supports reactively accessing attributes, properties, or methods on the underlying value. 143 | 144 | Here, we construct a simple class and show that we can create `Computed` objects that reactively track the value of attributes stored within he underlying object. 145 | 146 | ```python 147 | from dataclasses import dataclass 148 | from signified import Signal 149 | 150 | @dataclass 151 | class Person: 152 | name: str 153 | age: int 154 | 155 | def greet(self): 156 | return f"Hello, I'm {self.name} and I'm {self.age} years old!" 157 | 158 | person = Signal(Person("Alice", 30)) 159 | 160 | # Access attributes reactively 161 | name_display = person.name 162 | age_display = person.age 163 | greeting = person.greet() 164 | 165 | print(name_display) # "Alice" 166 | print(age_display) # 30 167 | print(greeting) # "Hello, I'm Alice and I'm 30 years old!" 168 | 169 | # Update through the signal 170 | person.name = "Bob" 171 | person.age = 35 172 | 173 | print(name_display) # "Bob" 174 | print(age_display) # 35 175 | print(greeting) # "Hello, I'm Bob and I'm 35 years old!" 176 | ``` 177 | 178 | Therefore, if the underlying object supports method chaining, we can easily create reactive values that apply several methods in sequence. 179 | 180 | ```python 181 | from signified import Signal 182 | 183 | text = Signal(" Hello, World! ") 184 | processed = text.strip().lower().replace(",", "") 185 | 186 | print(processed.value) # "hello world!" 187 | text.value = " Goodbye, World! " 188 | print(processed.value) # "goodbye world!" 189 | ``` 190 | 191 | ### The reactive_method decorator 192 | 193 | Use the `@reactive_method` decorator to turn a non-reactive method into a reactive one. 194 | 195 | ```python 196 | from dataclasses import dataclass 197 | from typing import List 198 | 199 | from signified import Signal, reactive_method 200 | 201 | @dataclass 202 | class Item: 203 | name: str 204 | price: float 205 | 206 | class Cart: 207 | def __init__(self, items: List[Item]): 208 | self.items = Signal(items) 209 | self.tax_rate = Signal(0.125) 210 | 211 | # Providing the names of the reactive values this method depends on tells 212 | # signified to monitor them for updates 213 | @reactive_method('items', 'tax_rate') 214 | def total(self): 215 | subtotal = sum(item.price for item in self.items.value) 216 | return subtotal * (1 + self.tax_rate) 217 | 218 | items = [Item(name="Book", price=20), Item(name="Pen", price=4)] 219 | cart = Cart(items) 220 | 221 | total_price = cart.total() 222 | print(total_price) # 27 (24 * 1.125) 223 | cart.tax_rate.value = 0.25 224 | print(total_price) # 30 (24 * 1.25) 225 | cart.items[0] = Item(name="Rare book?", price=400) 226 | print(total_price) # 505 (404 * 1.25) 227 | 228 | ``` 229 | 230 | ### Understanding `unref` 231 | 232 | The `unref` function is particularly useful when working with values that might be either reactive or non-reactive. This is common when writing functions that should handle both types transparently. 233 | 234 | ```python 235 | from signified import HasValue, Signal, unref 236 | 237 | def process_data(value: HasValue[float]): 238 | # unref handles both reactive and non-reactive values 239 | actual_value = unref(value) 240 | return actual_value * 2 241 | 242 | # Works with regular values 243 | regular_value = 4 244 | print(process_data(regular_value)) # 8 245 | 246 | # Works with reactive values 247 | reactive_value = Signal(5) 248 | print(process_data(reactive_value)) # 10 249 | 250 | # Works with nested reactive values 251 | nested_value = Signal(Signal(Signal(6))) 252 | print(process_data(nested_value)) # 12 253 | ``` 254 | -------------------------------------------------------------------------------- /examples/plugins/access_tracker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import time 5 | from collections import defaultdict 6 | from dataclasses import dataclass, field 7 | from enum import Enum, auto 8 | from typing import Any, Dict, List 9 | from weakref import WeakValueDictionary 10 | 11 | from signified import Signal, Variable, unref 12 | from signified.plugins import hookimpl, pm 13 | 14 | 15 | class EventType(Enum): 16 | READ = auto() 17 | WRITE = auto() 18 | CREATE = auto() 19 | 20 | 21 | @dataclass 22 | class AccessEvent: 23 | """Represents a single access to a reactive value.""" 24 | 25 | timestamp: datetime.datetime 26 | event_type: EventType 27 | value: Any 28 | 29 | 30 | @dataclass 31 | class AccessStats: 32 | read_count: int = 0 33 | write_count: int = 0 34 | creation_time: datetime.datetime = field(default_factory=datetime.datetime.now) 35 | last_access_time: datetime.datetime = field(default_factory=datetime.datetime.now) 36 | value_history: List[AccessEvent] = field(default_factory=list, repr=False) # Rename to be clearer 37 | 38 | def add_event(self, event_type: EventType, value: Any) -> None: 39 | now = datetime.datetime.now() 40 | 41 | if event_type == EventType.READ: 42 | self.read_count += 1 43 | self.last_access_time = now 44 | elif event_type in (EventType.WRITE, EventType.CREATE): 45 | if event_type == EventType.WRITE: 46 | self.write_count += 1 47 | elif event_type == EventType.CREATE: 48 | self.creation_time = now 49 | self.last_access_time = now 50 | self.value_history.append(AccessEvent(now, event_type, value)) 51 | 52 | @property 53 | def total_accesses(self) -> int: 54 | """Total number of reads and modifications.""" 55 | return self.read_count + self.write_count 56 | 57 | @property 58 | def last_value(self) -> Any: 59 | """Most recently recorded value.""" 60 | return self.value_history[-1].value if self.value_history else None 61 | 62 | 63 | class AccessTracker: 64 | """Plugin for tracking reactive value access patterns.""" 65 | 66 | def __init__(self): 67 | self.stats: Dict[int, AccessStats] = defaultdict(AccessStats) 68 | self._variables: WeakValueDictionary[int, Variable[Any, Any]] = WeakValueDictionary() 69 | 70 | def cleanup(self) -> None: 71 | """Clear stats.""" 72 | self.stats.clear() 73 | self._variables.clear() 74 | 75 | @hookimpl 76 | def created(self, value: Variable[Any, Any]) -> None: 77 | """Track creation of new reactive values.""" 78 | self.stats[id(value)].add_event(EventType.CREATE, unref(value)) 79 | self._variables[id(value)] = value 80 | 81 | @hookimpl 82 | def updated(self, value: Variable[Any, Any]) -> None: 83 | """Track modifications to reactive values.""" 84 | self.stats[id(value)].add_event(EventType.WRITE, unref(value)) 85 | 86 | @hookimpl 87 | def read(self, value: Variable[Any, Any]) -> None: 88 | """Record a read access to a reactive value.""" 89 | self.stats[id(value)].add_event(EventType.READ, unref(value)) 90 | 91 | def get_stats(self, value: Variable[Any, Any]) -> AccessStats: 92 | """Get access statistics for a specific reactive value.""" 93 | return self.stats[id(value)] 94 | 95 | def print_summary(self) -> None: 96 | """Print summary statistics for all tracked values.""" 97 | print("\nAccess Pattern Summary:") 98 | print("-" * 50) 99 | for value_id, stats in self.stats.items(): 100 | value = self._variables.get(value_id) 101 | print(f"Value {value:n}:") 102 | print(f"\tReads: {stats.read_count}") 103 | print(f"\tModifications: {stats.write_count}") 104 | print(f"\tTotal accesses: {stats.total_accesses}") 105 | if stats.creation_time and stats.last_access_time: 106 | lifetime = stats.last_access_time - stats.creation_time 107 | print(f"\tLifetime: {lifetime.total_seconds():.2f} seconds") 108 | print() 109 | 110 | 111 | # Create global tracker instance 112 | tracker = AccessTracker() 113 | 114 | # Register it 115 | pm.register(tracker) 116 | 117 | if __name__ == "__main__": 118 | import time 119 | 120 | # Create some reactive values and use them 121 | x = Signal(1).add_name("x") 122 | y = Signal(2).add_name("y") 123 | 124 | time.sleep(1) 125 | 126 | # Some modifications 127 | x.value = 10 # x read and write 128 | y.value = 20 # y read and write 129 | x.value = 30 # x read and write 130 | 131 | # More reads 132 | z = (x + y).add_name("z") # x and y read 133 | 134 | # Wait a bit to get some time differences 135 | time.sleep(1) 136 | 137 | # Print stats 138 | tracker.print_summary() 139 | 140 | # Get specific stats 141 | print(f"Value history for x: {[v.value for v in tracker.get_stats(x).value_history]}") 142 | print(f"Value history for y: {[v.value for v in tracker.get_stats(y).value_history]}") 143 | print(f"Value history for z: {[v.value for v in tracker.get_stats(z).value_history]}") 144 | 145 | # Cleanup 146 | pm.unregister(tracker) 147 | tracker.cleanup() 148 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Signified 2 | site_url: https://dougmercer.github.io/signified 3 | repo_url: https://github.com/dougmercer/signified 4 | 5 | theme: 6 | name: material 7 | custom_dir: docs/overrides 8 | palette: 9 | - media: "(prefers-color-scheme)" 10 | toggle: 11 | icon: material/lightbulb-auto 12 | name: Switch to light mode 13 | - media: '(prefers-color-scheme: light)' 14 | scheme: default 15 | primary: teal 16 | accent: amber 17 | toggle: 18 | icon: material/lightbulb 19 | name: Switch to dark mode 20 | - media: '(prefers-color-scheme: dark)' 21 | scheme: slate 22 | primary: teal 23 | accent: amber 24 | toggle: 25 | icon: material/lightbulb-outline 26 | name: Switch to system preference 27 | features: 28 | - announce.dismiss 29 | - content.code.annotate 30 | - content.code.copy 31 | - content.code.select 32 | - content.footnote.tooltips 33 | - content.tabs.link 34 | - content.tooltips 35 | - navigation.footer 36 | - navigation.indexes 37 | - navigation.instant 38 | - navigation.instant.prefetch 39 | - navigation.instant.preview 40 | - navigation.instant.progress 41 | - navigation.path 42 | - navigation.tabs 43 | - navigation.tabs.sticky 44 | - navigation.top 45 | - navigation.tracking 46 | - search.highlight 47 | - search.share 48 | - search.suggest 49 | - toc.follow 50 | 51 | markdown_extensions: 52 | # Python Markdown 53 | - abbr 54 | - admonition 55 | - attr_list 56 | - def_list 57 | - footnotes 58 | - md_in_html 59 | # - tables 60 | - toc: 61 | permalink: true 62 | # - pymdownx.arithmatex: 63 | # generic: true 64 | - pymdownx.betterem: 65 | smart_enable: all 66 | - pymdownx.caret 67 | - pymdownx.details 68 | - pymdownx.emoji: 69 | emoji_index: !!python/name:material.extensions.emoji.twemoji 70 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 71 | - pymdownx.highlight: 72 | anchor_linenums: true 73 | line_spans: __span 74 | pygments_lang_class: true 75 | - pymdownx.inlinehilite 76 | - pymdownx.keys 77 | # - pymdownx.magiclink: 78 | # normalize_issue_symbols: true 79 | # repo_url_shorthand: true 80 | # user: dougmercer 81 | # repo: keyed 82 | - pymdownx.mark 83 | - pymdownx.smartsymbols 84 | - pymdownx.snippets 85 | - pymdownx.superfences: 86 | custom_fences: 87 | - name: mermaid 88 | class: mermaid 89 | format: !!python/name:pymdownx.superfences.fence_code_format 90 | - pymdownx.tabbed: 91 | alternate_style: true 92 | combine_header_slug: true 93 | slugify: !!python/object/apply:pymdownx.slugs.slugify 94 | kwds: 95 | case: lower 96 | - pymdownx.tasklist: 97 | custom_checkbox: true 98 | - pymdownx.tilde 99 | 100 | # pymdownx blocks 101 | - pymdownx.blocks.admonition: 102 | types: 103 | - note 104 | - attention 105 | - caution 106 | - danger 107 | - error 108 | - tip 109 | - hint 110 | - warning 111 | # Custom types 112 | - info 113 | - check 114 | - pymdownx.blocks.details 115 | - pymdownx.blocks.tab: 116 | alternate_style: True 117 | - pymdownx.details 118 | - pymdownx.superfences 119 | 120 | nav: 121 | - Home: index.md 122 | - Usage: usage.md 123 | - API Reference: 124 | - Core: api/core.md 125 | - IPython: api/display.md 126 | - Types: api/types.md 127 | - Plugins: api/plugins.md 128 | - Change Log: changes.md 129 | - Limitations: limitations.md 130 | - Plugins: plugins.md 131 | 132 | plugins: 133 | - search 134 | - autorefs 135 | - mkdocstrings: 136 | default_handler: python 137 | handlers: 138 | python: 139 | paths: [src] 140 | inventories: 141 | - https://numpy.org/doc/stable/objects.inv 142 | options: 143 | docstring_style: google 144 | show_if_no_docstring: true 145 | inherited_members: true 146 | members_order: source 147 | filters: 148 | - '!^_' 149 | separate_signature: true 150 | signature_crossrefs: true 151 | show_symbol_type_toc: true 152 | show_symbol_type_heading: true 153 | show_root_heading: true 154 | show_source: false 155 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "signified" 7 | version = "0.2.3" 8 | dependencies = [ 9 | "ipython", 10 | "numpy", 11 | "pluggy", 12 | "typing-extensions; python_version < '3.11'" 13 | ] 14 | requires-python = ">=3.9" 15 | authors = [ 16 | {name = "Doug Mercer", email = "dougmerceryt@gmail.com"}, 17 | ] 18 | maintainers = [ 19 | {name = "Doug Mercer", email = "dougmerceryt@gmail.com"}, 20 | ] 21 | description = "Reactive Signals and Computed values." 22 | readme = "README.md" 23 | license = {file="LICENSE"} 24 | keywords = ["reactive", "signals"] 25 | classifiers = [ 26 | "Development Status :: 3 - Alpha", 27 | "Programming Language :: Python", 28 | ] 29 | 30 | [tool.setuptools.package-data] 31 | signified = ["py.typed"] 32 | 33 | [project.optional-dependencies] 34 | docs = [ 35 | "mkdocs", 36 | "mkdocs-material", 37 | "mkdocstrings[python]", 38 | "mkdocs-material-extensions", 39 | ] 40 | 41 | [project.urls] 42 | Homepage = "https://github.com/dougmercer/signified.git" 43 | Documentation = "https://dougmercer.github.io/signified" 44 | Repository = "https://github.com/dougmercer/signified.git" 45 | Issues = "https://github.com/dougmercer/signified/issues" 46 | Changelog = "https://dougmercer.github.io/signified/changelog" 47 | 48 | [tool.ruff] 49 | # Exclude a variety of commonly ignored directories. 50 | exclude = [ 51 | ".bzr", 52 | ".direnv", 53 | ".eggs", 54 | ".git", 55 | ".git-rewrite", 56 | ".hg", 57 | ".ipynb_checkpoints", 58 | ".mypy_cache", 59 | ".nox", 60 | ".pants.d", 61 | ".pyenv", 62 | ".pytest_cache", 63 | ".pytype", 64 | ".ruff_cache", 65 | ".svn", 66 | ".tox", 67 | ".venv", 68 | ".vscode", 69 | ".__pycache__", 70 | "__pypackages__", 71 | "_build", 72 | "buck-out", 73 | "build", 74 | "dist", 75 | "docs", 76 | "envs", 77 | "htmlcov", 78 | "results", 79 | "significant.egg-info", 80 | "junk", 81 | ".hypothesis", 82 | "node_modules", 83 | "site-packages", 84 | "venv", 85 | ] 86 | 87 | line-length = 120 88 | indent-width = 4 89 | target-version = "py39" 90 | 91 | [tool.ruff.lint] 92 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 93 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 94 | # McCabe complexity (`C901`) by default. 95 | select = ["E4", "E7", "E9", "F", "I"] 96 | ignore = [] 97 | 98 | # Allow fix for all enabled rules (when `--fix`) is provided. 99 | fixable = ["ALL"] 100 | unfixable = [] 101 | 102 | # Allow unused variables when underscore-prefixed. 103 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 104 | 105 | [tool.ruff.format] 106 | # Like Black, use double quotes for strings. 107 | quote-style = "double" 108 | 109 | # Like Black, indent with spaces, rather than tabs. 110 | indent-style = "space" 111 | 112 | # Like Black, respect magic trailing commas. 113 | skip-magic-trailing-comma = false 114 | 115 | # Like Black, automatically detect the appropriate line ending. 116 | line-ending = "auto" 117 | 118 | # Enable auto-formatting of code examples in docstrings. Markdown, 119 | # reStructuredText code/literal blocks and doctests are all supported. 120 | # 121 | # This is currently disabled by default, but it is planned for this 122 | # to be opt-out in the future. 123 | docstring-code-format = false 124 | 125 | # Set the line length limit used when formatting code snippets in 126 | # docstrings. 127 | # 128 | # This only has an effect when the `docstring-code-format` setting is 129 | # enabled. 130 | docstring-code-line-length = "dynamic" 131 | 132 | 133 | [tool.pytest.ini_options] 134 | addopts = "--doctest-modules --cov=src/ --cov-report=xml --junitxml=junit/test-results.xml" 135 | filterwarnings = [ 136 | "ignore::DeprecationWarning", 137 | ] 138 | testpaths = [ 139 | "tests", 140 | "src" 141 | ] 142 | 143 | [tool.pydocstyle] 144 | convention = "google" 145 | add_ignore = ["D107"] 146 | -------------------------------------------------------------------------------- /src/signified/__init__.py: -------------------------------------------------------------------------------- 1 | """A reactive programming library for creating and managing reactive values and computations. 2 | 3 | This module provides tools for building reactive systems, where changes in one value 4 | automatically propagate to dependent values. 5 | 6 | Classes: 7 | Variable: Abstract base class for reactive values. 8 | Signal: A container for mutable reactive values. 9 | Computed: A container for computed reactive values (from functions). 10 | 11 | Functions: 12 | unref: Dereference a potentially reactive value. 13 | computed: Decorator to create a reactive value from a function. 14 | reactive_method: Decorator to create a reactive method. 15 | as_signal: Convert a value to a Signal if it's not already a reactive value. 16 | has_value: Type guard to check if an object has a value of a specific type. 17 | 18 | Attributes: 19 | ReactiveValue: Union of Computed and Signal types. 20 | HasValue: Union of basic types and reactive types. 21 | NestedValue: Recursive type for arbitrarily nested reactive values. 22 | """ 23 | 24 | from .core import Computed, Signal, Variable 25 | from .types import HasValue, NestedValue, ReactiveValue, has_value 26 | from .utils import as_signal, computed, reactive_method, unref 27 | 28 | __all__ = [ 29 | "Variable", 30 | "Signal", 31 | "Computed", 32 | "computed", 33 | "reactive_method", 34 | "unref", 35 | "as_signal", 36 | "HasValue", 37 | "NestedValue", 38 | "ReactiveValue", 39 | "has_value", 40 | ] 41 | -------------------------------------------------------------------------------- /src/signified/core.py: -------------------------------------------------------------------------------- 1 | """Core reactive programming functionality.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from abc import ABC, abstractmethod 7 | from contextlib import contextmanager 8 | from typing import Any, Callable, Generator, Iterable, Protocol, cast 9 | 10 | import numpy as np 11 | from IPython.display import display 12 | 13 | from .ops import ReactiveMixIn 14 | from .plugins import pm 15 | from .types import HasValue, NestedValue, T, Y, _HasValue 16 | from .utils import unref 17 | 18 | if sys.version_info >= (3, 11): 19 | from typing import Self 20 | else: 21 | from typing_extensions import Self 22 | 23 | 24 | class Observer(Protocol): 25 | def update(self) -> None: 26 | pass 27 | 28 | 29 | class Variable(ABC, _HasValue[Y], ReactiveMixIn[T]): # type: ignore[misc] 30 | """An abstract base class for reactive values. 31 | 32 | A reactive value is an object that can be observed by observer for changes and 33 | can notify observers when its value changes. This class implements both the observer 34 | and observable patterns. 35 | 36 | This class implements both the observer and observable pattern. 37 | 38 | Subclasses should implement the `update` method. 39 | 40 | Attributes: 41 | _observers (list[Observer]): List of observers subscribed to this variable. 42 | """ 43 | 44 | __slots__ = ["_observers"] 45 | 46 | def __init__(self): 47 | """Initialize the variable.""" 48 | self._observers: list[Observer] = [] 49 | self.__name = "" 50 | 51 | def subscribe(self, observer: Observer) -> None: 52 | """Subscribe an observer to this variable. 53 | 54 | Args: 55 | observer: The observer to subscribe. 56 | """ 57 | if observer not in self._observers: 58 | self._observers.append(observer) 59 | 60 | def unsubscribe(self, observer: Observer) -> None: 61 | """Unsubscribe an observer from this variable. 62 | 63 | Args: 64 | observer: The observer to unsubscribe. 65 | """ 66 | if observer in self._observers: 67 | self._observers.remove(observer) 68 | 69 | def observe(self, items: Any) -> Self: 70 | """Subscribe the observer (`self`) to all items that are Observable. 71 | 72 | This method handles arbitrarily nested iterables. 73 | 74 | Args: 75 | items: A single item, an iterable, or a nested structure of items to potentially subscribe to. 76 | 77 | Returns: 78 | self 79 | """ 80 | 81 | def _observe(item: Any) -> None: 82 | if isinstance(item, Variable) and item is not self: 83 | item.subscribe(self) 84 | elif isinstance(item, Iterable) and not isinstance(item, str): 85 | for sub_item in item: 86 | _observe(sub_item) 87 | 88 | _observe(items) 89 | return self 90 | 91 | def unobserve(self, items: Any) -> Self: 92 | """Unsubscribe the observer (`self`) from all items that are Observable. 93 | 94 | Args: 95 | items: A single item or an iterable of items to potentially unsubscribe from. 96 | 97 | Returns: 98 | self 99 | """ 100 | 101 | def _unobserve(item: Any) -> None: 102 | if isinstance(item, Variable) and item is not self: 103 | item.unsubscribe(self) 104 | elif isinstance(item, Iterable) and not isinstance(item, str): 105 | for sub_item in item: 106 | _unobserve(sub_item) 107 | 108 | _unobserve(items) 109 | return self 110 | 111 | def notify(self) -> None: 112 | """Notify all observers by calling their update method.""" 113 | for observer in self._observers: 114 | observer.update() 115 | 116 | def __repr__(self) -> str: 117 | """Represent the object in a way that shows the inner value.""" 118 | return f"<{self.value}>" 119 | 120 | @abstractmethod 121 | def update(self) -> None: 122 | """Update method to be overridden by subclasses. 123 | 124 | Raises: 125 | NotImplementedError: If not overridden by a subclass. 126 | """ 127 | raise NotImplementedError("Update method should be overridden by subclasses") 128 | 129 | def _ipython_display_(self) -> None: 130 | from .display import IPythonObserver 131 | 132 | handle = display(self.value, display_id=True) 133 | assert handle is not None 134 | IPythonObserver(self, handle) 135 | 136 | def add_name(self, name: str) -> Self: 137 | self.__name = name 138 | pm.hook.named(value=self) 139 | return self 140 | 141 | def __format__(self, format_spec: str) -> str: 142 | """Format the variable with custom display options. 143 | 144 | Format options: 145 | :n - just the name (or type+id if unnamed) 146 | :d - full debug info 147 | empty - just the value in brackets (default) 148 | """ 149 | if not format_spec: # Default - just show value in brackets 150 | return f"<{self.value}>" 151 | if format_spec == "n": # Name only 152 | return self.__name if self.__name else f"{type(self).__name__}(id={id(self)})" 153 | if format_spec == "d": # Debug 154 | name_part = f"name='{self.__name}', " if self.__name else "" 155 | return f"{type(self).__name__}({name_part}value={self.value!r}, id={id(self)})" 156 | return super().__format__(format_spec) # Handles other format specs 157 | 158 | 159 | class Signal(Variable[NestedValue[T], T]): 160 | """A container that holds a reactive value.""" 161 | 162 | __slots__ = ["_value"] 163 | 164 | def __init__(self, value: NestedValue[T]) -> None: 165 | super().__init__() 166 | self._value: T = cast(T, value) 167 | self.observe(value) 168 | pm.hook.created(value=self) 169 | 170 | @property 171 | def value(self) -> T: 172 | """Get or set the current value.""" 173 | pm.hook.read(value=self) 174 | return unref(self._value) 175 | 176 | @value.setter 177 | def value(self, new_value: HasValue[T]) -> None: 178 | old_value = self._value 179 | change = new_value != old_value 180 | if isinstance(change, np.ndarray): 181 | change = change.any() 182 | elif callable(old_value): 183 | change = True 184 | if change: 185 | self._value = cast(T, new_value) 186 | pm.hook.updated(value=self) 187 | self.unobserve(old_value) 188 | self.observe(new_value) 189 | self.notify() 190 | 191 | @contextmanager 192 | def at(self, value: T) -> Generator[None, None, None]: 193 | """Temporarily set the signal to a given value within a context.""" 194 | before = self.value 195 | try: 196 | before = self.value 197 | self.value = value 198 | yield 199 | finally: 200 | self.value = before 201 | 202 | def update(self) -> None: 203 | """Update the signal and notify subscribers.""" 204 | self.notify() 205 | 206 | 207 | class Computed(Variable[T, T]): 208 | """A reactive value defined by a function.""" 209 | 210 | __slots__ = ["f", "_value"] 211 | 212 | def __init__(self, f: Callable[[], T], dependencies: Any = None) -> None: 213 | super().__init__() 214 | self.f = f 215 | self.observe(dependencies) 216 | self._value = unref(self.f()) 217 | self.notify() 218 | pm.hook.created(value=self) 219 | 220 | def update(self) -> None: 221 | """Update the value by re-evaluating the function.""" 222 | new_value = self.f() 223 | change = new_value != self._value 224 | if isinstance(change, np.ndarray): 225 | change = change.any() 226 | elif callable(self._value): 227 | change = True 228 | 229 | if change: 230 | self._value: T = new_value 231 | pm.hook.updated(value=self) 232 | self.notify() 233 | 234 | @property 235 | def value(self) -> T: 236 | """Get the current value.""" 237 | pm.hook.read(value=self) 238 | return unref(self._value) 239 | -------------------------------------------------------------------------------- /src/signified/display.py: -------------------------------------------------------------------------------- 1 | """IPython display integration for reactive values.""" 2 | 3 | from typing import Any 4 | 5 | from IPython.display import DisplayHandle 6 | 7 | 8 | class IPythonObserver: 9 | """Observer that updates IPython display when value changes.""" 10 | 11 | def __init__(self, me: Any, handle: DisplayHandle): 12 | self.me = me 13 | self.handle = handle 14 | me.subscribe(self) 15 | 16 | def update(self) -> None: 17 | self.handle.update(self.me.value) 18 | 19 | 20 | class Echo: 21 | """Observer that prints value changes to stdout.""" 22 | 23 | def __init__(self, me: Any): 24 | self.me = me 25 | me.subscribe(self) 26 | 27 | def update(self) -> None: 28 | print(self.me.value) 29 | -------------------------------------------------------------------------------- /src/signified/ops.py: -------------------------------------------------------------------------------- 1 | """Reactive operators and methods for reactive values.""" 2 | 3 | from __future__ import annotations 4 | 5 | import math 6 | import operator 7 | from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Union, cast, overload 8 | 9 | from .types import A, B, HasValue, R, T, Y 10 | from .utils import computed 11 | 12 | if TYPE_CHECKING: 13 | from .core import Computed 14 | 15 | 16 | class ReactiveMixIn(Generic[T]): 17 | """Methods for easily creating reactive values.""" 18 | 19 | @property 20 | def value(self) -> T: 21 | """The current value of the reactive object.""" 22 | ... 23 | 24 | def notify(self) -> None: 25 | """Notify all observers by calling their ``update`` method.""" 26 | ... 27 | 28 | @overload 29 | def __getattr__(self, name: Literal["value", "_value"]) -> T: ... # type: ignore 30 | 31 | @overload 32 | def __getattr__(self, name: str) -> Computed[Any]: ... 33 | 34 | def __getattr__(self, name: str) -> Union[T, Computed[Any]]: 35 | """Create a reactive value for retrieving an attribute from ``self.value``. 36 | 37 | Args: 38 | name: The name of the attribute to access. 39 | 40 | Returns: 41 | A reactive value for the attribute access. 42 | 43 | Raises: 44 | AttributeError: If the attribute doesn't exist. 45 | 46 | Note: 47 | Type inference is poor whenever `__getattr__` is used. 48 | 49 | Example: 50 | ```py 51 | >>> class Person: 52 | ... def __init__(self, name): 53 | ... self.name = name 54 | >>> s = Signal(Person("Alice")) 55 | >>> result = s.name 56 | >>> result.value 57 | 'Alice' 58 | >>> s.value = Person("Bob") 59 | >>> result.value 60 | 'Bob' 61 | 62 | ``` 63 | """ 64 | if name in {"value", "_value"}: 65 | return super().__getattribute__(name) 66 | 67 | if hasattr(self.value, name): 68 | return computed(getattr)(self, name) 69 | else: 70 | return super().__getattribute__(name) 71 | 72 | @overload 73 | def __call__(self: "ReactiveMixIn[Callable[..., R]]", *args: Any, **kwargs: Any) -> Computed[R]: ... 74 | 75 | @overload 76 | def __call__(self, *args: Any, **kwargs: Any) -> Any: ... 77 | def __call__(self, *args: Any, **kwargs: Any) -> Any: 78 | """Create a reactive value for calling `self.value(*args, **kwargs)`. 79 | 80 | Args: 81 | *args: Positional arguments to pass to the callable value. 82 | **kwargs: Keyword arguments to pass to the callable value. 83 | 84 | Returns: 85 | A reactive value for the function call. 86 | 87 | Raises: 88 | ValueError: If the value is not callable. 89 | 90 | Example: 91 | ```py 92 | >>> class Person: 93 | ... def __init__(self, name): 94 | ... self.name = name 95 | ... def greet(self): 96 | ... return f"Hi, I'm {self.name}!" 97 | >>> s = Signal(Person("Alice")) 98 | >>> result = s.greet() 99 | >>> result.value 100 | "Hi, I'm Alice!" 101 | >>> s.name = "Bob" 102 | >>> result.value 103 | "Hi, I'm Bob!" 104 | 105 | ``` 106 | """ 107 | if not callable(self.value): 108 | raise ValueError("Value is not callable.") 109 | 110 | def f(*args: Any, **kwargs: Any): 111 | _f = getattr(self, "value") 112 | return _f(*args, **kwargs) 113 | 114 | return computed(f)(*args, **kwargs).observe([self, self.value]) 115 | 116 | def __abs__(self) -> Computed[T]: 117 | """Return a reactive value for the absolute value of `self`. 118 | 119 | Returns: 120 | A reactive value for `abs(self.value)`. 121 | 122 | Example: 123 | ```py 124 | >>> s = Signal(-5) 125 | >>> result = abs(s) 126 | >>> result.value 127 | 5 128 | >>> s.value = -10 129 | >>> result.value 130 | 10 131 | 132 | ``` 133 | """ 134 | return computed(abs)(self) 135 | 136 | def bool(self) -> Computed[bool]: 137 | """Return a reactive value for the boolean value of `self`. 138 | 139 | Note: 140 | `__bool__` cannot be implemented to return a non-`bool`, so it is provided as a method. 141 | 142 | Returns: 143 | A reactive value for `bool(self.value)`. 144 | 145 | Example: 146 | ```py 147 | >>> s = Signal(1) 148 | >>> result = s.bool() 149 | >>> result.value 150 | True 151 | >>> s.value = 0 152 | >>> result.value 153 | False 154 | 155 | ``` 156 | """ 157 | return computed(bool)(self) 158 | 159 | def __str__(self) -> str: 160 | """Return a string of the current value. 161 | 162 | Note: 163 | This is not reactive. 164 | 165 | Returns: 166 | A string representation of `self.value`. 167 | """ 168 | return str(self.value) 169 | 170 | @overload 171 | def __round__(self) -> Computed[int]: ... 172 | @overload 173 | def __round__(self, ndigits: None) -> Computed[int]: ... 174 | @overload 175 | def __round__(self, ndigits: int) -> Computed[float]: ... 176 | 177 | def __round__(self, ndigits: int | None = None) -> Computed[int] | Computed[float]: 178 | """Return a reactive value for the rounded value of self. 179 | 180 | Args: 181 | ndigits: Number of decimal places to round to. 182 | 183 | Returns: 184 | A reactive value for `round(self.value, ndigits)`. 185 | 186 | Example: 187 | ```py 188 | >>> s = Signal(3.14159) 189 | >>> result = round(s, 2) 190 | >>> result.value 191 | 3.14 192 | >>> s.value = 2.71828 193 | >>> result.value 194 | 2.72 195 | 196 | ``` 197 | """ 198 | if ndigits is None or ndigits == 0: 199 | # When ndigits is None or 0, round returns an integer 200 | return cast("Computed[int]", computed(round)(self, ndigits=ndigits)) 201 | else: 202 | # Otherwise, float 203 | return cast("Computed[float]", computed(round)(self, ndigits=ndigits)) 204 | 205 | def __ceil__(self) -> Computed[int]: 206 | """Return a reactive value for the ceiling of `self`. 207 | 208 | Returns: 209 | A reactive value for `math.ceil(self.value)`. 210 | 211 | Example: 212 | ```py 213 | >>> from math import ceil 214 | >>> s = Signal(3.14) 215 | >>> result = ceil(s) 216 | >>> result.value 217 | 4 218 | >>> s.value = 2.01 219 | >>> result.value 220 | 3 221 | 222 | ``` 223 | """ 224 | return cast("Computed[int]", computed(math.ceil)(self)) 225 | 226 | def __floor__(self) -> Computed[int]: 227 | """Return a reactive value for the floor of `self`. 228 | 229 | Returns: 230 | A reactive value for `math.floor(self.value)`. 231 | 232 | Example: 233 | ```py 234 | >>> from math import floor 235 | >>> s = Signal(3.99) 236 | >>> result = floor(s) 237 | >>> result.value 238 | 3 239 | >>> s.value = 4.01 240 | >>> result.value 241 | 4 242 | 243 | ``` 244 | """ 245 | return cast("Computed[int]", computed(math.floor)(self)) 246 | 247 | def __invert__(self) -> Computed[T]: 248 | """Return a reactive value for the bitwise inversion of `self`. 249 | 250 | Returns: 251 | A reactive value for `~self.value`. 252 | 253 | Example: 254 | ```py 255 | >>> s = Signal(5) 256 | >>> result = ~s 257 | >>> result.value 258 | -6 259 | >>> s.value = -3 260 | >>> result.value 261 | 2 262 | 263 | ``` 264 | """ 265 | return computed(operator.inv)(self) 266 | 267 | def __neg__(self) -> Computed[T]: 268 | """Return a reactive value for the negation of `self`. 269 | 270 | Returns: 271 | A reactive value for `-self.value`. 272 | 273 | Example: 274 | ```py 275 | >>> s = Signal(5) 276 | >>> result = -s 277 | >>> result.value 278 | -5 279 | >>> s.value = -3 280 | >>> result.value 281 | 3 282 | 283 | ``` 284 | """ 285 | return computed(operator.neg)(self) 286 | 287 | def __pos__(self) -> Computed[T]: 288 | """Return a reactive value for the positive of self. 289 | 290 | Returns: 291 | A reactive value for `+self.value`. 292 | 293 | Example: 294 | ```py 295 | >>> s = Signal(-5) 296 | >>> result = +s 297 | >>> result.value 298 | -5 299 | >>> s.value = 3 300 | >>> result.value 301 | 3 302 | 303 | ``` 304 | """ 305 | return computed(operator.pos)(self) 306 | 307 | def __trunc__(self) -> Computed[T]: 308 | """Return a reactive value for the truncated value of `self`. 309 | 310 | Returns: 311 | A reactive value for `math.trunc(self.value)`. 312 | 313 | Example: 314 | ```py 315 | >>> from math import trunc 316 | >>> s = Signal(3.99) 317 | >>> result = trunc(s) 318 | >>> result.value 319 | 3 320 | >>> s.value = -4.01 321 | >>> result.value 322 | -4 323 | 324 | ``` 325 | """ 326 | return computed(math.trunc)(self) 327 | 328 | def __add__(self, other: HasValue[Y]) -> Computed[T | Y]: 329 | """Return a reactive value for the sum of `self` and `other`. 330 | 331 | Args: 332 | other: The value to add. 333 | 334 | Returns: 335 | A reactive value for `self.value + other.value`. 336 | 337 | Example: 338 | ```py 339 | >>> s = Signal(5) 340 | >>> result = s + 3 341 | >>> result.value 342 | 8 343 | >>> s.value = 10 344 | >>> result.value 345 | 13 346 | 347 | ``` 348 | """ 349 | f: Callable[[T, Y], T | Y] = operator.add 350 | return computed(f)(self, other) 351 | 352 | def __and__(self, other: HasValue[Y]) -> Computed[bool]: 353 | """Return a reactive value for the bitwise AND of self and other. 354 | 355 | Args: 356 | other: The value to AND with. 357 | 358 | Returns: 359 | A reactive value for `self.value & other.value`. 360 | 361 | Example: 362 | ```py 363 | >>> s = Signal(True) 364 | >>> result = s & False 365 | >>> result.value 366 | False 367 | >>> s.value = True 368 | >>> result.value 369 | False 370 | 371 | ``` 372 | """ 373 | return computed(operator.and_)(self, other) 374 | 375 | def contains(self, other: Any) -> Computed[bool]: 376 | """Return a reactive value for whether `other` is in `self`. 377 | 378 | Args: 379 | other: The value to check for containment. 380 | 381 | Returns: 382 | A reactive value for `other in self.value`. 383 | 384 | Example: 385 | ```py 386 | >>> s = Signal([1, 2, 3, 4]) 387 | >>> result = s.contains(3) 388 | >>> result.value 389 | True 390 | >>> s.value = [5, 6, 7, 8] 391 | >>> result.value 392 | False 393 | 394 | ``` 395 | """ 396 | return computed(operator.contains)(self, other) 397 | 398 | def __divmod__(self, other: Any) -> Computed[tuple[float, float]]: 399 | """Return a reactive value for the divmod of `self` and other. 400 | 401 | Args: 402 | other: The value to use as the divisor. 403 | 404 | Returns: 405 | A reactive value for `divmod(self.value, other)`. 406 | 407 | Example: 408 | ```py 409 | >>> s = Signal(10) 410 | >>> result = divmod(s, 3) 411 | >>> result.value 412 | (3, 1) 413 | >>> s.value = 20 414 | >>> result.value 415 | (6, 2) 416 | 417 | ``` 418 | """ 419 | return cast("Computed[tuple[float, float]]", computed(divmod)(self, other)) 420 | 421 | def is_not(self, other: Any) -> Computed[bool]: 422 | """Return a reactive value for whether `self` is not other. 423 | 424 | Args: 425 | other: The value to compare against. 426 | 427 | Returns: 428 | A reactive value for self.value is not other. 429 | 430 | Example: 431 | ```py 432 | >>> s = Signal(10) 433 | >>> other = None 434 | >>> result = s.is_not(other) 435 | >>> result.value 436 | True 437 | >>> s.value = None 438 | >>> result.value 439 | False 440 | 441 | ``` 442 | """ 443 | return computed(operator.is_not)(self, other) 444 | 445 | def eq(self, other: Any) -> Computed[bool]: 446 | """Return a reactive value for whether `self` equals other. 447 | 448 | Args: 449 | other: The value to compare against. 450 | 451 | Returns: 452 | A reactive value for self.value == other. 453 | 454 | Note: 455 | We can't overload `__eq__` because it interferes with basic Python operations. 456 | 457 | Example: 458 | ```py 459 | >>> from signified import Signal 460 | >>> s = Signal(10) 461 | >>> result = s.eq(10) 462 | >>> result.value 463 | True 464 | >>> s.value = 25 465 | >>> result.value 466 | False 467 | 468 | ``` 469 | """ 470 | return computed(operator.eq)(self, other) 471 | 472 | def __floordiv__(self, other: HasValue[Y]) -> Computed[T | Y]: 473 | """Return a reactive value for the floor division of `self` by other. 474 | 475 | Args: 476 | other: The value to use as the divisor. 477 | 478 | Returns: 479 | A reactive value for self.value // other.value. 480 | 481 | Example: 482 | ```py 483 | >>> s = Signal(20) 484 | >>> result = s // 3 485 | >>> result.value 486 | 6 487 | >>> s.value = 25 488 | >>> result.value 489 | 8 490 | 491 | ``` 492 | """ 493 | f: Callable[[T, Y], T | Y] = operator.floordiv 494 | return computed(f)(self, other) 495 | 496 | def __ge__(self, other: Any) -> Computed[bool]: 497 | """Return a reactive value for whether `self` is greater than or equal to other. 498 | 499 | Args: 500 | other: The value to compare against. 501 | 502 | Returns: 503 | A reactive value for self.value >= other. 504 | 505 | Example: 506 | ```py 507 | >>> s = Signal(10) 508 | >>> result = s >= 5 509 | >>> result.value 510 | True 511 | >>> s.value = 3 512 | >>> result.value 513 | False 514 | 515 | ``` 516 | """ 517 | return computed(operator.ge)(self, other) 518 | 519 | def __gt__(self, other: Any) -> Computed[bool]: 520 | """Return a reactive value for whether `self` is greater than other. 521 | 522 | Args: 523 | other: The value to compare against. 524 | 525 | Returns: 526 | A reactive value for self.value > other. 527 | 528 | Example: 529 | ```py 530 | >>> s = Signal(10) 531 | >>> result = s > 5 532 | >>> result.value 533 | True 534 | >>> s.value = 3 535 | >>> result.value 536 | False 537 | 538 | ``` 539 | """ 540 | return computed(operator.gt)(self, other) 541 | 542 | def __le__(self, other: Any) -> Computed[bool]: 543 | """Return a reactive value for whether `self` is less than or equal to `other`. 544 | 545 | Args: 546 | other: The value to compare against. 547 | 548 | Returns: 549 | A reactive value for `self.value <= other`. 550 | 551 | Example: 552 | ```py 553 | >>> s = Signal(5) 554 | >>> result = s <= 5 555 | >>> result.value 556 | True 557 | >>> s.value = 6 558 | >>> result.value 559 | False 560 | 561 | ``` 562 | """ 563 | return computed(operator.le)(self, other) 564 | 565 | def __lt__(self, other: Any) -> Computed[bool]: 566 | """Return a reactive value for whether `self` is less than `other`. 567 | 568 | Args: 569 | other: The value to compare against. 570 | 571 | Returns: 572 | A reactive value for `self.value < other`. 573 | 574 | Example: 575 | ```py 576 | >>> s = Signal(5) 577 | >>> result = s < 10 578 | >>> result.value 579 | True 580 | >>> s.value = 15 581 | >>> result.value 582 | False 583 | 584 | ``` 585 | """ 586 | return computed(operator.lt)(self, other) 587 | 588 | def __lshift__(self, other: HasValue[Y]) -> Computed[T | Y]: 589 | """Return a reactive value for `self` left-shifted by `other`. 590 | 591 | Args: 592 | other: The number of positions to shift. 593 | 594 | Returns: 595 | A reactive value for `self.value << other.value`. 596 | 597 | Example: 598 | ```py 599 | >>> s = Signal(8) 600 | >>> result = s << 2 601 | >>> result.value 602 | 32 603 | >>> s.value = 3 604 | >>> result.value 605 | 12 606 | 607 | ``` 608 | """ 609 | f: Callable[[T, Y], T | Y] = operator.lshift 610 | return computed(f)(self, other) 611 | 612 | def __matmul__(self, other: HasValue[Y]) -> Computed[T | Y]: 613 | """Return a reactive value for the matrix multiplication of `self` and `other`. 614 | 615 | Args: 616 | other: The value to multiply with. 617 | 618 | Returns: 619 | A reactive value for `self.value @ other.value`. 620 | 621 | Example: 622 | ```py 623 | >>> import numpy as np 624 | >>> s = Signal(np.array([1, 2])) 625 | >>> result = s @ np.array([[1, 2], [3, 4]]) 626 | >>> result.value 627 | array([ 7, 10]) 628 | >>> s.value = np.array([2, 3]) 629 | >>> result.value 630 | array([11, 16]) 631 | 632 | ``` 633 | """ 634 | f: Callable[[T, Y], T | Y] = operator.matmul 635 | return computed(f)(self, other) 636 | 637 | def __mod__(self, other: HasValue[Y]) -> Computed[T | Y]: 638 | """Return a reactive value for `self` modulo `other`. 639 | 640 | Args: 641 | other: The divisor. 642 | 643 | Returns: 644 | A reactive value for `self.value % other.value`. 645 | 646 | Example: 647 | ```py 648 | >>> s = Signal(17) 649 | >>> result = s % 5 650 | >>> result.value 651 | 2 652 | >>> s.value = 23 653 | >>> result.value 654 | 3 655 | 656 | ``` 657 | """ 658 | f: Callable[[T, Y], T | Y] = operator.mod 659 | return computed(f)(self, other) 660 | 661 | def __mul__(self, other: HasValue[Y]) -> Computed[T | Y]: 662 | """Return a reactive value for the product of `self` and `other`. 663 | 664 | Args: 665 | other: The value to multiply with. 666 | 667 | Returns: 668 | A reactive value for `self.value * other.value`. 669 | 670 | Example: 671 | ```py 672 | >>> s = Signal(4) 673 | >>> result = s * 3 674 | >>> result.value 675 | 12 676 | >>> s.value = 5 677 | >>> result.value 678 | 15 679 | 680 | ``` 681 | """ 682 | f: Callable[[T, Y], T | Y] = operator.mul 683 | return computed(f)(self, other) 684 | 685 | def __ne__(self, other: Any) -> Computed[bool]: # type: ignore[override] 686 | """Return a reactive value for whether `self` is not equal to `other`. 687 | 688 | Args: 689 | other: The value to compare against. 690 | 691 | Returns: 692 | A reactive value for `self.value != other`. 693 | 694 | Example: 695 | ```py 696 | >>> s = Signal(5) 697 | >>> result = s != 5 698 | >>> result.value 699 | False 700 | >>> s.value = 6 701 | >>> result.value 702 | True 703 | 704 | ``` 705 | """ 706 | return computed(operator.ne)(self, other) 707 | 708 | def __or__(self, other: Any) -> Computed[bool]: 709 | """Return a reactive value for the bitwise OR of `self` and `other`. 710 | 711 | Args: 712 | other: The value to OR with. 713 | 714 | Returns: 715 | A reactive value for `self.value or other.value`. 716 | 717 | Example: 718 | ```py 719 | >>> s = Signal(False) 720 | >>> result = s | True 721 | >>> result.value 722 | True 723 | >>> s.value = True 724 | >>> result.value 725 | True 726 | 727 | ``` 728 | """ 729 | return computed(operator.or_)(self, other) 730 | 731 | def __rshift__(self, other: HasValue[Y]) -> Computed[T | Y]: 732 | """Return a reactive value for `self` right-shifted by `other`. 733 | 734 | Args: 735 | other: The number of positions to shift. 736 | 737 | Returns: 738 | A reactive value for `self.value >> other.value`. 739 | 740 | Example: 741 | ```py 742 | >>> s = Signal(32) 743 | >>> result = s >> 2 744 | >>> result.value 745 | 8 746 | >>> s.value = 24 747 | >>> result.value 748 | 6 749 | 750 | ``` 751 | """ 752 | f: Callable[[T, Y], T | Y] = operator.rshift 753 | return computed(f)(self, other) 754 | 755 | def __pow__(self, other: HasValue[Y]) -> Computed[T | Y]: 756 | """Return a reactive value for `self` raised to the power of `other`. 757 | 758 | Args: 759 | other: The exponent. 760 | 761 | Returns: 762 | A reactive value for `self.value ** other.value`. 763 | 764 | Example: 765 | ```py 766 | >>> s = Signal(2) 767 | >>> result = s ** 3 768 | >>> result.value 769 | 8 770 | >>> s.value = 3 771 | >>> result.value 772 | 27 773 | 774 | ``` 775 | """ 776 | f: Callable[[T, Y], T | Y] = operator.pow 777 | return computed(f)(self, other) 778 | 779 | def __sub__(self, other: HasValue[Y]) -> Computed[T | Y]: 780 | """Return a reactive value for the difference of `self` and `other`. 781 | 782 | Args: 783 | other: The value to subtract. 784 | 785 | Returns: 786 | A reactive value for `self.value - other.value`. 787 | 788 | Example: 789 | ```py 790 | >>> s = Signal(10) 791 | >>> result = s - 3 792 | >>> result.value 793 | 7 794 | >>> s.value = 15 795 | >>> result.value 796 | 12 797 | 798 | ``` 799 | """ 800 | f: Callable[[T, Y], T | Y] = operator.sub 801 | return computed(f)(self, other) 802 | 803 | def __truediv__(self, other: HasValue[Y]) -> Computed[T | Y]: 804 | """Return a reactive value for `self` divided by `other`. 805 | 806 | Args: 807 | other: The value to use as the divisor. 808 | 809 | Returns: 810 | A reactive value for `self.value / other.value`. 811 | 812 | Example: 813 | ```py 814 | >>> s = Signal(20) 815 | >>> result = s / 4 816 | >>> result.value 817 | 5.0 818 | >>> s.value = 30 819 | >>> result.value 820 | 7.5 821 | 822 | ``` 823 | """ 824 | f: Callable[[T, Y], T | Y] = operator.truediv 825 | return computed(f)(self, other) 826 | 827 | def __xor__(self, other: Any) -> Computed[bool]: 828 | """Return a reactive value for the bitwise XOR of `self` and `other`. 829 | 830 | Args: 831 | other: The value to XOR with. 832 | 833 | Returns: 834 | A reactive value for `self.value ^ other.value`. 835 | 836 | Example: 837 | ```py 838 | >>> s = Signal(True) 839 | >>> result = s ^ False 840 | >>> result.value 841 | True 842 | >>> s.value = False 843 | >>> result.value 844 | False 845 | 846 | ``` 847 | """ 848 | return computed(operator.xor)(self, other) 849 | 850 | def __radd__(self, other: HasValue[Y]) -> Computed[T | Y]: 851 | """Return a reactive value for the sum of `self` and `other`. 852 | 853 | Args: 854 | other: The value to add. 855 | 856 | Returns: 857 | A reactive value for `self.value + other.value`. 858 | 859 | Example: 860 | ```py 861 | >>> s = Signal(5) 862 | >>> result = 3 + s 863 | >>> result.value 864 | 8 865 | >>> s.value = 10 866 | >>> result.value 867 | 13 868 | 869 | ``` 870 | """ 871 | f: Callable[[Y, T], T | Y] = operator.add 872 | return computed(f)(other, self) 873 | 874 | def __rand__(self, other: Any) -> Computed[bool]: 875 | """Return a reactive value for the bitwise AND of `self` and `other`. 876 | 877 | Args: 878 | other: The value to AND with. 879 | 880 | Returns: 881 | A reactive value for `self.value and other.value`. 882 | 883 | Example: 884 | ```py 885 | >>> s = Signal(True) 886 | >>> result = False & s 887 | >>> result.value 888 | False 889 | >>> s.value = True 890 | >>> result.value 891 | False 892 | 893 | ``` 894 | """ 895 | return computed(operator.and_)(other, self) 896 | 897 | def __rdivmod__(self, other: Any) -> Computed[tuple[float, float]]: 898 | """Return a reactive value for the divmod of `self` and `other`. 899 | 900 | Args: 901 | other: The value to use as the numerator. 902 | 903 | Returns: 904 | A reactive value for `divmod(other, self.value)`. 905 | 906 | Example: 907 | ```py 908 | >>> s = Signal(3) 909 | >>> result = divmod(10, s) 910 | >>> result.value 911 | (3, 1) 912 | >>> s.value = 4 913 | >>> result.value 914 | (2, 2) 915 | 916 | ``` 917 | """ 918 | return cast("Computed[tuple[float, float]]", computed(divmod)(other, self)) 919 | 920 | def __rfloordiv__(self, other: HasValue[Y]) -> Computed[T | Y]: 921 | """Return a reactive value for the floor division of `other` by `self`. 922 | 923 | Args: 924 | other: The value to use as the numerator. 925 | 926 | Returns: 927 | A reactive value for `other.value // self.value`. 928 | 929 | Example: 930 | ```py 931 | >>> s = Signal(3) 932 | >>> result = 10 // s 933 | >>> result.value 934 | 3 935 | >>> s.value = 4 936 | >>> result.value 937 | 2 938 | 939 | ``` 940 | """ 941 | f: Callable[[Y, T], T | Y] = operator.floordiv 942 | return computed(f)(other, self) 943 | 944 | def __rmod__(self, other: HasValue[Y]) -> Computed[T | Y]: 945 | """Return a reactive value for `other` modulo `self`. 946 | 947 | Args: 948 | other: The dividend. 949 | 950 | Returns: 951 | A reactive value for `other.value % self.value`. 952 | 953 | Example: 954 | ```py 955 | >>> s = Signal(3) 956 | >>> result = 10 % s 957 | >>> result.value 958 | 1 959 | >>> s.value = 4 960 | >>> result.value 961 | 2 962 | 963 | ``` 964 | """ 965 | f: Callable[[Y, T], T | Y] = operator.mod 966 | return computed(f)(other, self) 967 | 968 | def __rmul__(self, other: HasValue[Y]) -> Computed[T | Y]: 969 | """Return a reactive value for the product of `self` and `other`. 970 | 971 | Args: 972 | other: The value to multiply with. 973 | 974 | Returns: 975 | A reactive value for `self.value * other.value`. 976 | 977 | Example: 978 | ```py 979 | >>> s = Signal(4) 980 | >>> result = 3 * s 981 | >>> result.value 982 | 12 983 | >>> s.value = 5 984 | >>> result.value 985 | 15 986 | 987 | ``` 988 | """ 989 | f: Callable[[Y, T], T | Y] = operator.mul 990 | return computed(f)(other, self) 991 | 992 | def __ror__(self, other: Any) -> Computed[bool]: 993 | """Return a reactive value for the bitwise OR of `self` and `other`. 994 | 995 | Args: 996 | other: The value to OR with. 997 | 998 | Returns: 999 | A reactive value for `self.value or other.value`. 1000 | 1001 | Example: 1002 | ```py 1003 | >>> s = Signal(False) 1004 | >>> result = True | s 1005 | >>> result.value 1006 | True 1007 | >>> s.value = True 1008 | >>> result.value 1009 | True 1010 | 1011 | ``` 1012 | """ 1013 | return computed(operator.or_)(other, self) 1014 | 1015 | def __rpow__(self, other: HasValue[Y]) -> Computed[T | Y]: 1016 | """Return a reactive value for `self` raised to the power of `other`. 1017 | 1018 | Args: 1019 | other: The base. 1020 | 1021 | Returns: 1022 | A reactive value for `self.value ** other.value`. 1023 | 1024 | Example: 1025 | ```py 1026 | >>> s = Signal(2) 1027 | >>> result = 3 ** s 1028 | >>> result.value 1029 | 9 1030 | >>> s.value = 3 1031 | >>> result.value 1032 | 27 1033 | 1034 | ``` 1035 | """ 1036 | f: Callable[[Y, T], T | Y] = operator.pow 1037 | return computed(f)(other, self) 1038 | 1039 | def __rsub__(self, other: HasValue[Y]) -> Computed[T | Y]: 1040 | """Return a reactive value for the difference of `self` and `other`. 1041 | 1042 | Args: 1043 | other: The value to subtract from. 1044 | 1045 | Returns: 1046 | A reactive value for `other.value - self.value`. 1047 | 1048 | Example: 1049 | ```py 1050 | >>> s = Signal(10) 1051 | >>> result = 15 - s 1052 | >>> result.value 1053 | 5 1054 | >>> s.value = 15 1055 | >>> result.value 1056 | 0 1057 | 1058 | ``` 1059 | """ 1060 | f: Callable[[Y, T], T | Y] = operator.sub 1061 | return computed(f)(other, self) 1062 | 1063 | def __rtruediv__(self, other: HasValue[Y]) -> Computed[T | Y]: 1064 | """Return a reactive value for `self` divided by `other`. 1065 | 1066 | Args: 1067 | other: The value to use as the divisor. 1068 | 1069 | Returns: 1070 | A reactive value for `self.value / other.value`. 1071 | 1072 | Example: 1073 | ```py 1074 | >>> s = Signal(2) 1075 | >>> result = 30 / s 1076 | >>> result.value 1077 | 15.0 1078 | >>> s.value = 3 1079 | >>> result.value 1080 | 10.0 1081 | 1082 | ``` 1083 | """ 1084 | f: Callable[[Y, T], T | Y] = operator.truediv 1085 | return computed(f)(other, self) 1086 | 1087 | def __rxor__(self, other: Any) -> Computed[bool]: 1088 | """Return a reactive value for the bitwise XOR of `self` and `other`. 1089 | 1090 | Args: 1091 | other: The value to XOR with. 1092 | 1093 | Returns: 1094 | A reactive value for `self.value ^ other.value`. 1095 | 1096 | Example: 1097 | ```py 1098 | >>> s = Signal(True) 1099 | >>> result = False ^ s 1100 | >>> result.value 1101 | True 1102 | >>> s.value = False 1103 | >>> result.value 1104 | False 1105 | 1106 | ``` 1107 | """ 1108 | return computed(operator.xor)(other, self) 1109 | 1110 | def __getitem__(self, key: Any) -> Computed[Any]: 1111 | """Return a reactive value for the item or slice of `self`. 1112 | 1113 | Args: 1114 | key: The index or slice to retrieve. 1115 | 1116 | Returns: 1117 | A reactive value for `self.value[key]`. 1118 | 1119 | Example: 1120 | ```py 1121 | >>> s = Signal([1, 2, 3, 4, 5]) 1122 | >>> result = s[2] 1123 | >>> result.value 1124 | 3 1125 | >>> s.value = [10, 20, 30, 40, 50] 1126 | >>> result.value 1127 | 30 1128 | 1129 | ``` 1130 | """ 1131 | return computed(operator.getitem)(self, key) 1132 | 1133 | def __setattr__(self, name: str, value: Any) -> None: 1134 | """Set an attribute on the underlying `self.value`. 1135 | 1136 | Note: 1137 | It is necessary to set the attribute via the Signal, rather than the 1138 | underlying `signal.value`, to properly notify downstream observers 1139 | of changes. Reason being, mutable objects that, for example, fallback 1140 | to id comparison for equality checks will appear as if nothing changed 1141 | even if one of its attributes changed. 1142 | 1143 | Args: 1144 | name: The name of the attribute to access. 1145 | value: The value to set it to. 1146 | 1147 | Example: 1148 | ```py 1149 | >>> class Person: 1150 | ... def __init__(self, name: str): 1151 | ... self.name = name 1152 | ... def greet(self) -> str: 1153 | ... return f"Hi, I'm {self.name}!" 1154 | >>> s = Signal(Person("Alice")) 1155 | >>> result = s.greet() 1156 | >>> result.value 1157 | "Hi, I'm Alice!" 1158 | >>> s.name = "Bob" # Modify attribute on Person instance through the reactive value s 1159 | >>> result.value 1160 | "Hi, I'm Bob!" 1161 | 1162 | ``` 1163 | """ 1164 | if name == "_value" or not hasattr(self, "_value"): 1165 | super().__setattr__(name, value) 1166 | elif hasattr(self.value, name): 1167 | setattr(self.value, name, value) 1168 | self.notify() 1169 | else: 1170 | super().__setattr__(name, value) 1171 | 1172 | def __setitem__(self, key: Any, value: Any) -> None: 1173 | """Set an item on the underlying `self.value`. 1174 | 1175 | Note: 1176 | It is necessary to set the item via the Signal, rather than the 1177 | underlying `signal.value`, to properly notify downstream observers 1178 | of changes. Reason being, mutable objects that, for example, fallback 1179 | to id comparison for equality checks will appear as if nothing changed 1180 | even an element of the object is changed. 1181 | 1182 | Args: 1183 | key: The key to change. 1184 | value: The value to set it to. 1185 | 1186 | Example: 1187 | ```py 1188 | >>> s = Signal([1, 2, 3]) 1189 | >>> result = computed(sum)(s) 1190 | >>> result.value 1191 | 6 1192 | >>> s[1] = 4 1193 | >>> result.value 1194 | 8 1195 | """ 1196 | if isinstance(self.value, (list, dict)): 1197 | self.value[key] = value 1198 | self.notify() 1199 | else: 1200 | raise TypeError(f"'{type(self.value).__name__}' object does not support item assignment") 1201 | 1202 | def where(self, a: HasValue[A], b: HasValue[B]) -> Computed[A | B]: 1203 | """Return a reactive value for `a` if `self` is `True`, else `b`. 1204 | 1205 | Args: 1206 | a: The value to return if `self` is `True`. 1207 | b: The value to return if `self` is `False`. 1208 | 1209 | Returns: 1210 | A reactive value for `a if self.value else b`. 1211 | 1212 | Example: 1213 | ```py 1214 | >>> condition = Signal(True) 1215 | >>> result = condition.where("Yes", "No") 1216 | >>> result.value 1217 | 'Yes' 1218 | >>> condition.value = False 1219 | >>> result.value 1220 | 'No' 1221 | 1222 | ``` 1223 | """ 1224 | 1225 | @computed 1226 | def ternary(a: A, b: B, self: Any) -> A | B: 1227 | return a if self else b 1228 | 1229 | return ternary(a, b, self) 1230 | -------------------------------------------------------------------------------- /src/signified/plugins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import pluggy 6 | 7 | __all__ = ["hookimpl", "pm"] 8 | 9 | if TYPE_CHECKING: 10 | from signified import Variable 11 | 12 | hookspec = pluggy.HookspecMarker("signified") 13 | hookimpl = pluggy.HookimplMarker("signified") 14 | 15 | 16 | class SignifiedHookSpec: 17 | """Hook specifications for the Signified library.""" 18 | 19 | @hookspec 20 | def read(self, value: Variable[Any, Any]) -> None: 21 | """Called when a new reactive value is created.""" 22 | 23 | @hookspec 24 | def created(self, value: Variable[Any, Any]) -> None: 25 | """Called when a new reactive value is created.""" 26 | 27 | @hookspec 28 | def updated(self, value: Variable[Any, Any]) -> None: 29 | """Called when a reactive value is updated.""" 30 | 31 | @hookspec 32 | def named(self, value: Variable[Any, Any]) -> None: 33 | """Called when a reactive value has been named.""" 34 | 35 | 36 | pm = pluggy.PluginManager("signified") 37 | pm.add_hookspecs(SignifiedHookSpec) 38 | -------------------------------------------------------------------------------- /src/signified/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dougmercer/signified/c5597977de0138e26fa957375948b0eace0f00d1/src/signified/py.typed -------------------------------------------------------------------------------- /src/signified/types.py: -------------------------------------------------------------------------------- 1 | """Type definitions for reactive programming.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import TypeAliasType 10 | else: 11 | from typing_extensions import TypeAliasType 12 | 13 | if sys.version_info >= (3, 10): 14 | from typing import ParamSpec, TypeAlias, TypeGuard 15 | else: 16 | from typing_extensions import ParamSpec, TypeAlias, TypeGuard 17 | 18 | if TYPE_CHECKING: 19 | from .core import Computed, Signal 20 | 21 | # Type variables 22 | T = TypeVar("T") 23 | Y = TypeVar("Y") 24 | R = TypeVar("R") 25 | 26 | A = TypeVar("A") 27 | B = TypeVar("B") 28 | 29 | P = ParamSpec("P") 30 | 31 | 32 | class _HasValue(Generic[T]): 33 | """Class to make pyright happy with type inference.""" 34 | 35 | @property 36 | def value(self) -> T: ... 37 | 38 | 39 | NestedValue: TypeAlias = Union[T, "_HasValue[NestedValue[T]]"] 40 | """Recursive type hint for arbitrarily nested reactive values.""" 41 | 42 | 43 | ReactiveValue = TypeAliasType("ReactiveValue", Union["Computed[T]", "Signal[T]"], type_params=(T,)) 44 | """A reactive object that would return a value of type T when calling unref(obj).""" 45 | 46 | HasValue = TypeAliasType("HasValue", Union[T, "Computed[T]", "Signal[T]"], type_params=(T,)) 47 | """This object would return a value of type T when calling unref(obj).""" 48 | 49 | 50 | def has_value(obj: Any, type_: type[T]) -> TypeGuard[HasValue[T]]: 51 | """Check if an object has a value of a specific type.""" 52 | from .utils import unref 53 | 54 | return isinstance(unref(obj), type_) 55 | -------------------------------------------------------------------------------- /src/signified/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for reactive programming.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from functools import wraps 7 | from typing import TYPE_CHECKING, Any, Callable, Iterable, cast 8 | 9 | import numpy as np 10 | 11 | from .types import HasValue, P, R, T 12 | 13 | if sys.version_info >= (3, 10): 14 | from typing import Concatenate 15 | else: 16 | from typing_extensions import Concatenate 17 | 18 | if TYPE_CHECKING: 19 | from .core import Computed, Signal 20 | 21 | 22 | def unref(value: HasValue[T]) -> T: 23 | """Dereference a value, resolving any nested reactive variables.""" 24 | # Imported locally to avoid circular imports 25 | from .core import Variable 26 | 27 | while isinstance(value, Variable): 28 | value = value._value 29 | return cast(T, value) 30 | 31 | 32 | def deep_unref(value: Any) -> Any: 33 | """Recursively `unref` values potentially within containers.""" 34 | # Imported locally to avoid circular imports 35 | from .core import Variable 36 | 37 | # Base case - if it's a reactive value, unref it 38 | if isinstance(value, Variable): 39 | return deep_unref(unref(value)) 40 | 41 | # For containers, recursively unref their elements 42 | if isinstance(value, np.ndarray): 43 | return np.array([deep_unref(item) for item in value]).reshape(value.shape) if value.dtype == object else value 44 | if isinstance(value, dict): 45 | return {deep_unref(unref(k)): deep_unref(unref(v)) for k, v in value.items()} 46 | if isinstance(value, (list, tuple)): 47 | return type(value)(deep_unref(item) for item in value) 48 | if isinstance(value, Iterable) and not isinstance(value, str): 49 | try: 50 | return type(value)(deep_unref(item) for item in value) # pyright: ignore[reportCallIssue] 51 | except TypeError: 52 | # This is not some plain old iterable initialized by *args. Just return as-is 53 | return value 54 | 55 | # For non-containers/non-reactive values, return as-is 56 | return value 57 | 58 | 59 | def computed(func: Callable[..., R]) -> Callable[..., Computed[R]]: 60 | """Decorate the function to return a reactive value.""" 61 | 62 | @wraps(func) 63 | def wrapper(*args: Any, **kwargs: Any) -> Computed[R]: 64 | # Import here to avoid circular imports 65 | from .core import Computed 66 | 67 | def compute_func() -> R: 68 | resolved_args = tuple(deep_unref(arg) for arg in args) 69 | resolved_kwargs = {key: deep_unref(value) for key, value in kwargs.items()} 70 | return func(*resolved_args, **resolved_kwargs) 71 | 72 | return Computed(compute_func, (*args, *kwargs.values())) 73 | 74 | return wrapper 75 | 76 | 77 | # Note: `Any` is used to handle `self` in methods. 78 | InstanceMethod = Callable[Concatenate[Any, P], T] 79 | ReactiveMethod = Callable[Concatenate[Any, P], "Computed[T]"] 80 | 81 | 82 | def reactive_method(*dep_names: str) -> Callable[[InstanceMethod[P, T]], ReactiveMethod[P, T]]: 83 | """Decorate the method to return a reactive value.""" 84 | 85 | def decorator(func: InstanceMethod[P, T]) -> ReactiveMethod[P, T]: 86 | @wraps(func) 87 | def wrapper(self: Any, *args: Any, **kwargs: Any) -> Computed[T]: 88 | # Import here to avoid circular imports 89 | from .core import Computed 90 | 91 | object_deps = [getattr(self, name) for name in dep_names if hasattr(self, name)] 92 | all_deps = (*object_deps, *args, *kwargs.values()) 93 | return Computed(lambda: func(self, *args, **kwargs), all_deps) 94 | 95 | return wrapper 96 | 97 | return decorator 98 | 99 | 100 | def as_signal(val: HasValue[T]) -> Signal[T]: 101 | """Convert a value to a Signal if it's not already a reactive value.""" 102 | # Import here to avoid circular imports 103 | from .core import Signal, Variable 104 | 105 | return cast(Signal[T], val) if isinstance(val, Variable) else Signal(val) 106 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from signified import Computed, Signal, computed 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def add_signal(doctest_namespace): 8 | doctest_namespace["Signal"] = Signal 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def add_computed(doctest_namespace): 13 | doctest_namespace["Computed"] = Computed 14 | doctest_namespace["computed"] = computed 15 | -------------------------------------------------------------------------------- /tests/test_computed.py: -------------------------------------------------------------------------------- 1 | from signified import Computed, Signal, computed 2 | 3 | 4 | def test_computed_basic(): 5 | """Test basic Computed functionality.""" 6 | s = Signal(5) 7 | c = Computed(lambda: s.value * 2, dependencies=[s]) 8 | 9 | assert c.value == 10 10 | 11 | s.value = 7 12 | assert c.value == 14 13 | 14 | 15 | def test_computed_decorator(): 16 | """Test the @computed decorator.""" 17 | s = Signal(5) 18 | 19 | @computed 20 | def double_it(x): 21 | return x * 2 22 | 23 | c = double_it(s) 24 | assert c.value == 10 25 | 26 | s.value = 7 27 | assert c.value == 14 28 | 29 | 30 | def test_computed_dependencies(): 31 | """Test Computed with multiple dependencies.""" 32 | s1 = Signal(5) 33 | s2 = Signal(10) 34 | 35 | @computed 36 | def add_em(a, b): 37 | return a + b 38 | 39 | c = add_em(s1, s2) 40 | assert c.value == 15 41 | 42 | s1.value = 7 43 | assert c.value == 17 44 | 45 | s2.value = 13 46 | assert c.value == 20 47 | 48 | 49 | def test_computed_with_nested_signals(): 50 | """Test Computed with multiple nested Signals.""" 51 | s1 = Signal(Signal(5)) 52 | s2 = Signal(Signal(Signal(3))) 53 | 54 | @computed 55 | def complex_computation(a, b): 56 | return a * b 57 | 58 | result = complex_computation(s1, s2) 59 | assert result.value == 15 60 | 61 | s1.value = 7 62 | assert result.value == 21 63 | 64 | 65 | def test_computed_chaining(): 66 | """Test chaining of Computed values.""" 67 | s = Signal(5) 68 | c1 = computed(lambda x: x * 2)(s) 69 | c2 = computed(lambda x: x + 3)(c1) 70 | 71 | assert c2.value == 13 72 | s.value = 10 73 | assert c2.value == 23 74 | 75 | 76 | def test_computed_container_with_reactive_values(): 77 | s = [1, Signal(2), Signal(3)] 78 | result = computed(sum)(s) 79 | assert result.value == 6 80 | s[-1].value = 10 81 | assert result.value == 13 82 | 83 | 84 | def test_computed_container_with_deeply_nestedreactive_values(): 85 | def flatten(lst): 86 | result = [] 87 | for item in lst: 88 | if isinstance(item, list): 89 | result.extend(flatten(item)) 90 | else: 91 | result.append(item) 92 | return result 93 | 94 | s = [1, [Signal(2), Signal([Signal(3), Signal([4, Signal(5)])])], Signal(6)] 95 | result = computed(flatten)(s) 96 | assert result.value == [1, 2, 3, 4, 5, 6] 97 | s[1][0].value = 10 98 | assert result.value == [1, 10, 3, 4, 5, 6] 99 | -------------------------------------------------------------------------------- /tests/test_reactive_methods.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from signified import Signal 4 | 5 | 6 | def test_signal_arithmetic(): 7 | """Test Signal arithmetic operations.""" 8 | s1 = Signal(5) 9 | s2 = Signal(10) 10 | 11 | sum_computed = s1 + s2 12 | assert sum_computed.value == 15 13 | 14 | s1.value = 7 15 | assert sum_computed.value == 17 16 | 17 | 18 | def test_signal_comparison(): 19 | """Test Signal comparison operations.""" 20 | s1 = Signal(5) 21 | s2 = Signal(10) 22 | 23 | gt_computed = s2 > s1 24 | assert gt_computed.value == True # noqa: E712 25 | 26 | s1.value = 15 27 | assert gt_computed.value == False # noqa: E712 28 | 29 | 30 | def test_signal_arithmetic_operations(): 31 | """Test various arithmetic operations on Signals.""" 32 | s1 = Signal(5) 33 | s2 = Signal(3) 34 | 35 | assert (s1 + s2).value == 8 36 | assert (s1 - s2).value == 2 37 | assert (s1 * s2).value == 15 38 | assert (s1 / s2).value == 5 / 3 39 | assert (s1 // s2).value == 1 40 | assert (s1 % s2).value == 2 41 | assert (s1**s2).value == 125 42 | 43 | 44 | def test_signal_comparison_operations(): 45 | """Test comparison operations on Signals.""" 46 | s1 = Signal(5) 47 | s2 = Signal(3) 48 | 49 | assert (s1 > s2).value == True # noqa: E712 50 | assert (s1 >= s2).value == True # noqa: E712 51 | assert (s1 < s2).value == False # noqa: E712 52 | assert (s1 <= s2).value == False # noqa: E712 53 | assert s1.eq(s2).value == False # noqa: E712 54 | assert (s1 != s2).value == True # noqa: E712 55 | 56 | 57 | def test_signal_boolean_operations(): 58 | """Test boolean operations on Signals.""" 59 | s1 = Signal(True) 60 | s2 = Signal(False) 61 | 62 | assert (s1 & s2).value == False # noqa: E712 63 | assert (s1 | s2).value == True # noqa: E712 64 | assert (s1 ^ s2).value == True # noqa: E712 65 | assert (s1 ^ s1).value == False # noqa: E712 66 | 67 | 68 | def test_signal_bitwise_operations(): 69 | """Test bitwise operations on Signals.""" 70 | s1 = Signal(0b0101) # 5 in binary 71 | s2 = Signal(0b0011) # 3 in binary 72 | 73 | assert (s1 & s2).value == 0b0001 # Bitwise AND 74 | assert (s1 | s2).value == 0b0111 # Bitwise OR 75 | assert (s1 ^ s2).value == 0b0110 # Bitwise XOR 76 | assert (~s1).value == ~0b0101 # Bitwise NOT 77 | assert (s1 << 1).value == 0b1010 # Left shift 78 | assert (s1 >> 1).value == 0b0010 # Right shift 79 | 80 | 81 | def test_signal_inplace_operations(): 82 | """Test in-place operations on Signals.""" 83 | s = Signal(5) 84 | 85 | s.value += 3 86 | assert s.value == 8 87 | 88 | s.value *= 2 89 | assert s.value == 16 90 | 91 | s.value //= 3 92 | assert s.value == 5 93 | 94 | 95 | def test_signal_attribute_access(): 96 | """Test attribute access on Signal containing an object.""" 97 | 98 | class MyObj: 99 | def __init__(self): 100 | self.x = 5 101 | self.y = 10 102 | 103 | s = Signal(MyObj()) 104 | 105 | assert s.x.value == 5 106 | assert s.y.value == 10 107 | 108 | 109 | def test_signal_method_call(): 110 | """Test method calls on Signal containing an object.""" 111 | 112 | class MyObj: 113 | def __init__(self, value): 114 | self.value = value 115 | 116 | def double(self): 117 | return self.value * 2 118 | 119 | s = Signal(MyObj(5)) 120 | 121 | assert s.double().value == 10 122 | 123 | 124 | def test_signal_indexing(): 125 | """Test indexing on Signal containing a sequence.""" 126 | s = Signal([1, 2, 3, 4, 5]) 127 | 128 | assert s[0].value == 1 129 | assert s[-1].value == 5 130 | assert s[1:4].value == [2, 3, 4] 131 | 132 | 133 | def test_signal_contains(): 134 | """Test 'in' operator on Signal containing a sequence.""" 135 | s = Signal([1, 2, 3, 4, 5]) 136 | 137 | assert s.contains(3).value == True # noqa: E712 138 | assert s.contains(6).value == False # noqa: E712 139 | 140 | 141 | def test_signal_bool(): 142 | """Test boolean evaluation of Signal.""" 143 | s1 = Signal(1) 144 | s2 = Signal(0) 145 | s3 = Signal([]) 146 | s4 = Signal([1, 2, 3]) 147 | 148 | assert s1.bool().value == True # noqa: E712 149 | assert s2.bool().value == False # noqa: E712 150 | assert s3.bool().value == False # noqa: E712 151 | assert s4.bool().value == True # noqa: E712 152 | 153 | 154 | def test_signal_math_functions(): 155 | """Test math functions on Signals.""" 156 | s = Signal(15.1) 157 | 158 | assert math.ceil(s).value == 16 159 | assert math.floor(s).value == 15 160 | assert round(s).value == 15 161 | assert abs(s).value == 15.1 162 | 163 | s.value = -15.1 164 | assert abs(s).value == 15.1 165 | 166 | 167 | def test_signal_where(): 168 | """Test the 'where' method on Signals.""" 169 | condition = Signal(True) 170 | s1 = Signal(5) 171 | s2 = Signal(10) 172 | 173 | result = condition.where(s1, s2) 174 | assert result.value == 5 175 | 176 | condition.value = False 177 | assert result.value == 10 178 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from signified import Computed, Signal, unref 2 | 3 | 4 | def test_signal_basic(): 5 | """Test basic Signal functionality.""" 6 | s = Signal(5) 7 | assert s.value == 5 8 | 9 | s.value = 10 10 | assert s.value == 10 11 | 12 | 13 | def test_signal_nested(): 14 | """Test nested Signal functionality.""" 15 | s1 = Signal(5) 16 | s2 = Signal(s1) 17 | s3 = Signal(s2) 18 | 19 | assert s3.value == 5 20 | 21 | s1.value = 10 22 | assert s3.value == 10 23 | 24 | 25 | def test_unref(): 26 | """Test the unref function.""" 27 | s = Signal(5) 28 | c = Computed(lambda: s.value * 2, dependencies=[s]) 29 | 30 | assert unref(s) == 5 31 | assert unref(c) == 10 32 | assert unref(15) == 15 33 | 34 | 35 | def test_signal_observer(): 36 | """Test Signal observer pattern.""" 37 | s = Signal(5) 38 | 39 | class Appender: 40 | """An observer that appends values whenever a signal changes.""" 41 | 42 | def __init__(self, s: Signal): 43 | self.s = s 44 | self.values = [] 45 | 46 | def update(self): 47 | self.values.append(self.s.value) 48 | 49 | appender = Appender(s) 50 | s.subscribe(appender) 51 | 52 | s.value = 10 53 | s.value = 15 54 | 55 | assert appender.values == [10, 15] 56 | 57 | 58 | def test_signal_context_manager(): 59 | """Test the Signal's context manager functionality.""" 60 | s = Signal(5) 61 | t = Signal(s) 62 | 63 | with s.at(10): 64 | assert s.value == 10 65 | assert t.value == 10 66 | 67 | assert s.value == 5 68 | assert t.value == 5 69 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from signified import Computed, Signal, as_signal, has_value, reactive_method, unref 2 | 3 | 4 | def test_has_value(): 5 | """Test the has_value type guard function.""" 6 | s = Signal(5) 7 | c = Computed(lambda: 10) 8 | 9 | assert has_value(s, int) 10 | assert has_value(c, int) 11 | assert has_value(15, int) 12 | assert not has_value(s, str) 13 | 14 | 15 | def test_unref_nested_signals(): 16 | """Test unref function with deeply nested Signals.""" 17 | s = Signal(Signal(Signal(Signal(5)))) 18 | assert unref(s) == 5 19 | 20 | 21 | def test_reactive_method_decorator(): 22 | """Test the reactive_method decorator.""" 23 | 24 | class MyClass: 25 | def __init__(self): 26 | self.x = Signal(5) 27 | self.y = Signal(10) 28 | 29 | @reactive_method("x", "y") 30 | def sum(self): 31 | return self.x.value + self.y.value 32 | 33 | obj = MyClass() 34 | result = obj.sum() 35 | 36 | assert result.value == 15 37 | obj.x.value = 7 38 | assert result.value == 17 39 | 40 | 41 | def test_as_signal(): 42 | """Test the as_signal function.""" 43 | s1 = as_signal(5) 44 | s2 = as_signal(Signal(10)) 45 | 46 | assert isinstance(s1, Signal) 47 | assert isinstance(s2, Signal) 48 | assert s1.value == 5 49 | assert s2.value == 10 50 | -------------------------------------------------------------------------------- /tests/type_inference.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import TypeVar, Union 3 | 4 | from signified import Computed, Signal, computed, unref 5 | 6 | if sys.version_info >= (3, 11): 7 | from typing import assert_type 8 | else: 9 | from typing_extensions import assert_type 10 | 11 | T = TypeVar("T") 12 | 13 | Numeric = Union[int, float] 14 | 15 | 16 | def test_signal_types(): 17 | a = Signal(1) 18 | assert_type(a, Signal[int]) 19 | assert_type(a.value, int) 20 | 21 | b = Signal(a) 22 | assert_type(b, Signal[int]) 23 | assert_type(b.value, int) 24 | 25 | c = Signal(b) 26 | assert_type(c, Signal[int]) 27 | assert_type(c.value, int) 28 | 29 | d = Signal(Signal(Signal(Signal(Signal(1.2))))) 30 | assert_type(d, Signal[float]) 31 | assert_type(d.value, float) 32 | 33 | 34 | def test_computed_types(): 35 | def blah() -> float: 36 | return 1.1 37 | 38 | def bloo() -> int: 39 | return 1 40 | 41 | e = Computed(blah) 42 | assert_type(e, Computed[float]) 43 | assert_type(e.value, float) 44 | 45 | f = Computed(bloo) 46 | assert_type(f, Computed[int]) 47 | assert_type(f.value, int) 48 | 49 | 50 | def test_arithmetic_types(): 51 | a = Signal(1) 52 | b = Signal(2) 53 | c = Signal(3.0) 54 | 55 | result = a + b 56 | assert_type(result, Computed[int]) 57 | assert_type(unref(result), int) 58 | 59 | result = a + c 60 | assert_type(result, Computed[Numeric]) 61 | assert_type(unref(result), Numeric) 62 | 63 | 64 | def test_comparison_types(): 65 | a = Signal(1) 66 | b = Signal(Signal(Signal(2))) 67 | 68 | result = a > b 69 | assert_type(result, Computed[bool]) 70 | assert_type(unref(result), bool) 71 | 72 | 73 | def test_where_types(): 74 | a = Signal(1) 75 | b = Signal(2.0) 76 | condition = Signal(True) 77 | 78 | result = condition.where(a, b) 79 | assert_type(result, Computed[Numeric]) 80 | assert_type(unref(result), Numeric) 81 | 82 | 83 | def test_unref_types(): 84 | a = Signal(1) 85 | b = Signal(Signal(2.0)) 86 | c = Computed(lambda: "three") 87 | 88 | assert_type(unref(a), int) 89 | assert_type(unref(b), float) 90 | assert_type(unref(c), str) 91 | 92 | 93 | def test_complex_expression_types(): 94 | a = Signal(1) 95 | b = Signal(2.0) 96 | c = Computed(lambda: 3) 97 | 98 | result = (a + b) * c 99 | assert_type(result, Computed[Numeric]) 100 | assert_type(unref(result), Numeric) 101 | 102 | 103 | def test_call_inference(): 104 | class Person: 105 | def __init__(self, name: str): 106 | self.name = name 107 | 108 | def __call__(self, formal=False) -> str: 109 | return f"{'Greetings' if formal else 'Hi'}, I'm {self.name}!" 110 | 111 | a = computed(lambda: Person("Doug"))() 112 | 113 | assert_type(a, Computed[Person]) 114 | assert_type(a(), Computed[str]) 115 | --------------------------------------------------------------------------------