├── .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 | [](https://pypi.org/project/signified/)
4 | [](https://pypi.org/project/signified/)
5 | [](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 |
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 |
--------------------------------------------------------------------------------