├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile └── source │ ├── api.rst │ ├── conf.py │ ├── index.rst │ ├── install.rst │ ├── static │ └── custom.css │ └── usage │ ├── builtin-model-types.rst │ ├── handling-events.rst │ ├── spectate-in-traitlets.rst │ ├── spectating-other-types.rst │ └── the-basics.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── spectate ├── __init__.py ├── base.py ├── events.py ├── models.py ├── mvc.py ├── py.typed └── utils.py ├── tests ├── __init__.py ├── mock.py ├── test_base.py ├── test_dict.py ├── test_events.py ├── test_list.py ├── test_object.py └── test_set.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode 3 | .idea 4 | 5 | # PyEnv 6 | .python-version 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | MANIFEST 17 | .Python 18 | env/ 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | *.whl 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | .ipynb_checkpoints 66 | .pytest_cache 67 | Untitled*.ipynb 68 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: python 4 | 5 | cache: pip 6 | install: 7 | - pip install --upgrade pip 8 | - pip install -r requirements.txt 9 | - pip install . 10 | env: 11 | - TEST_CMD="pytest" 12 | 13 | script: eval $TEST_CMD 14 | 15 | matrix: 16 | include: 17 | - name: "dist" 18 | python: 3.8 19 | env: 20 | - TEST_CMD="python setup.py sdist bdist_wheel; pip install readme_renderer; twine check dist/*" 21 | - name: "black" 22 | python: 3.8 23 | env: 24 | - TEST_CMD="black --check ." 25 | - name: "flake8" 26 | python: 3.8 27 | env: 28 | - TEST_CMD="flake8" 29 | - name: "mypy" 30 | python: 3.8 31 | env: 32 | - TEST_CMD="mypy spectate" 33 | - name: "python-3.6" 34 | python: 3.6 35 | - name: "python-3.7" 36 | python: 3.7 37 | - name: "python-3.8" 38 | python: 3.8 39 | - name: "python-3.9" 40 | python: 3.9 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Ryan S. Morshead 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.md 2 | include spectate/py.typed 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/rmorshea/spectate.svg?branch=master)](https://travis-ci.org/rmorshea/spectate/branches) 2 | [![Documentation Status](https://readthedocs.org/projects/python-spectate/badge/?version=latest)](http://python-spectate.readthedocs.io/en/latest/?badge=latest) 3 | [![Version Info](https://img.shields.io/pypi/v/spectate.svg)](https://pypi.python.org/pypi/spectate) 4 | 5 | # Spectate 6 | 7 | A library for Python 3.6 and above that can track changes to mutable data types. 8 | 9 | With `spectate`, complicated protocols for managing updates don't need to be the outward responsibility of a user and can instead be done automagically in the background. For instance, syncing the state between a server and client can controlled by `spectate`, so users don't have to. 10 | 11 | 12 | # Documentation 13 | 14 | https://python-spectate.readthedocs.io/en/latest/ 15 | 16 | 17 | # Install 18 | 19 | + stable 20 | 21 | ```bash 22 | pip install spectate 23 | ``` 24 | 25 | + pre-release 26 | 27 | ```bash 28 | pip install spectate --pre 29 | ``` 30 | 31 | + master 32 | 33 | ```bash 34 | pip install git+https://github.com/rmorshea/spectate.git#egg=spectate 35 | ``` 36 | 37 | + developer 38 | 39 | ```bash 40 | git clone https://github.com/rmorshea/spectate && cd spectate/ && pip install -e . -r requirements.txt 41 | ``` 42 | 43 | 44 | # At A Glance 45 | 46 | If you're using Python 3.6 and above, create a model object 47 | 48 | ```python 49 | from spectate import mvc 50 | 51 | l = mvc.List() 52 | ``` 53 | 54 | Register a view function to it that observes changes 55 | 56 | ```python 57 | @mvc.view(l) 58 | def printer(l, events): 59 | for e in events: 60 | print(e) 61 | ``` 62 | 63 | Then modify your object and watch the view function react 64 | 65 | ```python 66 | l.append(0) 67 | l[0] = 1 68 | l.extend([2, 3]) 69 | ``` 70 | 71 | ``` 72 | {'index': 0, 'old': Undefined, 'new': 0} 73 | {'index': 0, 'old': 0, 'new': 1} 74 | {'index': 1, 'old': Undefined, 'new': 2} 75 | {'index': 2, 'old': Undefined, 'new': 3} 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Spectate 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. automodule:: spectate.models 5 | :members: 6 | 7 | .. automodule:: spectate.base 8 | :members: 9 | 10 | .. automodule:: spectate.events 11 | :members: 12 | 13 | .. automodule:: spectate.utils 14 | :members: 15 | 16 | .. automodule:: spectate.mvc 17 | :members: 18 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | 15 | import os 16 | import sys 17 | from importlib import import_module 18 | 19 | sys.path.insert(0, os.path.dirname(os.path.abspath(".."))) 20 | spectate = import_module("spectate") 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = "Spectate" 25 | copyright = "2021, Ryan Morshead" 26 | author = "Ryan Morshead" 27 | 28 | # The short X.Y version 29 | version = ".".join(spectate.__version__.split(".")[:2]) 30 | # The full version, including alpha/beta/rc tags 31 | release = spectate.__version__ 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.doctest", 45 | "sphinx.ext.coverage", 46 | "sphinx.ext.viewcode", 47 | "sphinx.ext.autosummary", 48 | "sphinx.ext.intersphinx", 49 | "sphinx.ext.autosectionlabel", 50 | "sphinx.ext.napoleon", # load napolean before type hints ext 51 | "sphinx_autodoc_typehints", 52 | ] 53 | 54 | # Add any paths that contain templates here, relative to this directory. 55 | templates_path = [".templates"] 56 | 57 | # The suffix(es) of source filenames. 58 | # You can specify multiple suffix as a list of string: 59 | # 60 | # source_suffix = ['.rst', '.md'] 61 | source_suffix = ".rst" 62 | 63 | # The master toctree document. 64 | master_doc = "index" 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This pattern also affects html_static_path and html_extra_path . 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = "manni" 80 | 81 | # -- Options for Inter-Sphinx Mapping ---------------------------------------- 82 | 83 | intersphinx_mapping = { 84 | "py2": ("https://docs.python.org/2", None), 85 | "py3": ("https://docs.python.org/3", None), 86 | } 87 | 88 | # -- Options for Autodoc ----------------------------------------------------- 89 | 90 | autodoc_default_flags = ["show-inheritance"] 91 | 92 | # -- Options for HTML output ------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | # 97 | html_theme = "furo" 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | # 103 | # html_theme_options = {} 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | html_static_path = ["static"] 109 | 110 | # Custom sidebar templates, must be a dictionary that maps document names 111 | # to template names. 112 | # 113 | # The default sidebars (for documents that don't match any pattern) are 114 | # defined by theme itself. Builtin themes are using these templates by 115 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 116 | # 'searchbox.html']``. 117 | # 118 | # html_sidebars = {} 119 | 120 | 121 | # -- Options for HTMLHelp output --------------------------------------------- 122 | 123 | # Output file base name for HTML help builder. 124 | htmlhelp_basename = "spectatedoc" 125 | 126 | 127 | # -- Options for LaTeX output ------------------------------------------------ 128 | 129 | latex_elements = { 130 | # The paper size ('letterpaper' or 'a4paper'). 131 | # 132 | # 'papersize': 'letterpaper', 133 | # The font size ('10pt', '11pt' or '12pt'). 134 | # 135 | # 'pointsize': '10pt', 136 | # Additional stuff for the LaTeX preamble. 137 | # 138 | # 'preamble': '', 139 | # Latex figure (float) alignment 140 | # 141 | # 'figure_align': 'htbp', 142 | } 143 | 144 | # Grouping the document tree into LaTeX files. List of tuples 145 | # (source start file, target name, title, 146 | # author, documentclass [howto, manual, or own class]). 147 | latex_documents = [ 148 | (master_doc, "spectate.tex", "Spectate Documentation", "Ryan Morshead", "manual") 149 | ] 150 | 151 | 152 | # -- Options for manual page output ------------------------------------------ 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [(master_doc, "Spectate", "Spectate Documentation", [author], 1)] 157 | 158 | 159 | # -- Options for Texinfo output ---------------------------------------------- 160 | 161 | # Grouping the document tree into Texinfo files. List of tuples 162 | # (source start file, target name, title, author, 163 | # dir menu entry, description, category) 164 | texinfo_documents = [ 165 | ( 166 | master_doc, 167 | "Spectate", 168 | "Spectate Documentation", 169 | author, 170 | "Spectate", 171 | "One line description of project.", 172 | "Miscellaneous", 173 | ) 174 | ] 175 | 176 | 177 | # -- App setup --------------------------------------------------------------- 178 | 179 | 180 | def setup(app): 181 | app.add_stylesheet("custom.css") 182 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Spectate |release| 2 | ================== 3 | 4 | A library that can track changes to mutable data types. With Spectate complicated 5 | protocols for managing updates, don't need to be the outward responsibility of a user, 6 | and can instead be done automagically in the background. 7 | 8 | .. toctree:: 9 | :hidden: 10 | 11 | install 12 | usage/the-basics 13 | usage/handling-events 14 | usage/builtin-model-types 15 | usage/spectating-other-types 16 | usage/spectate-in-traitlets 17 | 18 | .. toctree:: 19 | :hidden: 20 | :caption: Other Resources 21 | 22 | api 23 | Source Code 24 | 25 | 26 | At A Glance 27 | ----------- 28 | 29 | If you're using Python 3.6 and above, create a :mod:`spectate.mvc` object 30 | 31 | .. code-block:: python 32 | 33 | from spectate import mvc 34 | 35 | l = mvc.List() 36 | 37 | Register a view function to it that observes changes 38 | 39 | .. code-block:: python 40 | 41 | @mvc.view(l) 42 | def printer(l, events): 43 | for e in events: 44 | print(e) 45 | 46 | Then modify your object and watch the view function react 47 | 48 | .. code-block:: python 49 | 50 | l.append(0) 51 | l[0] = 1 52 | l.extend([2, 3]) 53 | 54 | .. code-block:: text 55 | 56 | {'index': 0, 'old': Undefined, 'new': 0} 57 | {'index': 0, 'old': 0, 'new': 1} 58 | {'index': 1, 'old': Undefined, 'new': 2} 59 | {'index': 2, 'old': Undefined, 'new': 3} 60 | -------------------------------------------------------------------------------- /docs/source/install.rst: -------------------------------------------------------------------------------- 1 | Install 2 | ======= 3 | 4 | Install ``spectate`` with `pip`_: 5 | 6 | .. code-block:: bash 7 | 8 | pip install spectate 9 | 10 | Development 11 | ----------- 12 | 13 | If you'd like to work with the source code, then clone the repository from github: 14 | 15 | .. code-block:: bash 16 | 17 | git clone git@github.com:rmorshea/spectate.git && cd spectate 18 | 19 | And do an editable install with `pip`_ that includes ``requirements.txt``: 20 | 21 | .. code-block:: bash 22 | 23 | pip install -e . -r requirements.txt 24 | 25 | .. Links 26 | .. ===== 27 | 28 | .. _pip: https://pip.pypa.io/en/stable/quickstart/ 29 | -------------------------------------------------------------------------------- /docs/source/static/custom.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | @media screen and (min-width: 767px) { 3 | 4 | .wy-table-responsive table td { 5 | /* !important prevents the common CSS stylesheets from overriding 6 | this as on RTD they are loaded after this stylesheet */ 7 | white-space: normal !important; 8 | } 9 | 10 | .wy-table-responsive { 11 | overflow: visible !important; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/source/usage/builtin-model-types.rst: -------------------------------------------------------------------------------- 1 | Builtin Model Types 2 | =================== 3 | 4 | Spectate provides a number of builtin model types that you can use out of the box. 5 | For most users these built-in types should be enough, however if you're adventurous, 6 | then you can create your own :ref:`Custom Models`. 7 | 8 | 9 | Dictionary 10 | ---------- 11 | 12 | The :class:`~spectate.models.Dict` model is a subclass of Python's standard 13 | :class:`dict`. This will produce events when the value of a key in the dictionary 14 | changes or is deleted. This will result when calling methods like :meth:`dict.update` 15 | and :meth:`dict.pop`, but also when using the normal syntax to set or delete an item. 16 | Events produced by :class:`~spectate.models.Dict` have the following fields: 17 | 18 | .. list-table:: 19 | :widths: 1 10 20 | :header-rows: 1 21 | 22 | * - Field 23 | - Description 24 | 25 | * - ``key`` 26 | - The key in the dict model that changed. 27 | 28 | * - ``old`` 29 | - * The value that was present in the key before the change 30 | * Is :attr:`~spectate.models.Undefined` if the index was not present. 31 | 32 | * - ``new`` 33 | - * The value that this is now present after the change 34 | * Is :attr:`~spectate.models.Undefined` if the index was deleted. 35 | 36 | 37 | List 38 | ---- 39 | 40 | The :class:`~spectate.models.List` model is a subclass of Python's standard 41 | :class:`list`. This model will produce events when an element of the list changes 42 | or an element changes from one position to another. This may happen when calling 43 | methods like :meth:`list.append` or :meth:`list.remove`, but also when using the 44 | normal syntax to set or delete an item. Events produced by 45 | :class:`~spectate.models.List` have the following keys: 46 | 47 | .. list-table:: 48 | :widths: 1 10 49 | :header-rows: 1 50 | 51 | * - Field 52 | - Description 53 | 54 | * - ``index`` 55 | - The index in the dict model that changed. 56 | 57 | * - ``old`` 58 | - * The value that was present before the change 59 | * Is :attr:`~spectate.models.Undefined` if the key was not present. 60 | 61 | * - ``new`` 62 | - * The value that this is now present after the change 63 | * Is :attr:`~spectate.models.Undefined` if the key was deleted. 64 | 65 | 66 | Set 67 | --- 68 | 69 | The :class:`~spectate.models.Set` model is a subclass of Python's standard 70 | :class:`set`. This model will produce events when an element of the set changes. 71 | This may happen when calling methods like :meth:`set.add` or :meth:`set.discard`. 72 | Events produced by :class:`~spectate.models.Set` have the following keys: 73 | 74 | .. list-table:: 75 | :widths: 1 10 76 | :header-rows: 1 77 | 78 | * - Field 79 | - Description 80 | 81 | * - ``old`` 82 | - A set of values that were removed due to the change. 83 | 84 | * - ``new`` 85 | - A set of the values that were added due to the change. 86 | 87 | 88 | Object 89 | ------ 90 | 91 | The :class:`~spectate.models.Object` model is a subclass of Python's standard 92 | :class:`object`. This model will produce events when an attribute of the object changes 93 | or is deleted. This may happen when using :func:`setattr` or :func:`delattr`, but also 94 | when using the normal syntax to set or delete attributes. Events produced by 95 | :class:`~spectate.models.Object` have the following keys: 96 | 97 | .. list-table:: 98 | :widths: 1 10 99 | :header-rows: 1 100 | 101 | * - Field 102 | - Description 103 | 104 | * - ``attr`` 105 | - The attribute in the model that changed. 106 | 107 | * - ``old`` 108 | - * The value that was present before the change 109 | * Is :attr:`~spectate.models.Undefined` if the attribute was not present. 110 | 111 | * - ``new`` 112 | - * The value that this is now present after the change 113 | * Is :attr:`~spectate.models.Undefined` if the key was deleted. 114 | -------------------------------------------------------------------------------- /docs/source/usage/handling-events.rst: -------------------------------------------------------------------------------- 1 | Handling Events 2 | =============== 3 | 4 | Spectate provides a series of context managers which allow you to capture and then 5 | modify events before they are distributed to views. This allows you to 6 | :ref:`hold `, :ref:`rollback `, and even 7 | :ref:`mute ` events. These context managers are useful for handling 8 | edge cases in your code, improving performance by :ref:`merging ` 9 | events, or :ref:`undo ` unwanted changes. 10 | 11 | 12 | Holding Events 13 | -------------- 14 | 15 | It's often useful to withhold sending notifications until all your changes are complete. 16 | Using the :func:`~spectate.events.hold` context manager, events created when 17 | modifying a model won't be distributed until we exit the context: 18 | 19 | .. code-block:: python 20 | 21 | d = mvc.Dict() 22 | 23 | # effectively the same as the printer view above 24 | mvc.view(d, lambda d, e: list(map(print, e))) 25 | 26 | print("before") 27 | with mvc.hold(d): 28 | d["a"] = 1 29 | print("during") 30 | # notifications are sent upon exiting 31 | print("after") 32 | 33 | .. code-block:: text 34 | 35 | before 36 | during 37 | {'key': 'a', 'old': Undefined, 'new': 1} 38 | after 39 | 40 | 41 | Merging Events 42 | '''''''''''''' 43 | 44 | Sometimes there is a block of code in which it's possible to produce duplicate events 45 | or events which could be merged into one. By passing in a ``reducer`` to 46 | :func:`~spectate.events.hold` you can change the list of events just before they 47 | are distributed. This is done by having the ``reducer`` return or yield the new events. 48 | 49 | .. code-block:: python 50 | 51 | from spectate import mvc 52 | 53 | d = mvc.Dict() 54 | 55 | mvc.view(d, lambda _, es: list(map(print, es))) 56 | 57 | def merge_dict_events(model, events): 58 | changes = {} 59 | 60 | for e in events: 61 | if e.key in changes: 62 | changes[e.key][1] = e.new 63 | else: 64 | changes[e.key] = [e.old, e.new] 65 | 66 | for key, (old, new) in changes.items(): 67 | yield {"key": key, "new": new, "old": old} 68 | 69 | with mvc.hold(d, reducer=merge_dict_events): 70 | for i in range(5): 71 | # this loop would normally produce 5 different events 72 | d["a"] = i 73 | 74 | .. code-block:: text 75 | 76 | {'key': 'a', 'new': Undefined, 'old': 4} 77 | 78 | 79 | Rolling Back Events 80 | ------------------- 81 | 82 | When an error occurs while modifying a model you may not want to distribute events. 83 | Using :func:`~spectate.events.rollback` you can suppress events that were produced 84 | in the same context as an error: 85 | 86 | .. code-block:: python 87 | 88 | from spectate import mvc 89 | 90 | d = mvc.Dict() 91 | 92 | @mvc.view(d) 93 | def should_not_be_called(d, events): 94 | # we never call this view 95 | assert False 96 | 97 | try: 98 | with mvc.rollback(d): 99 | d["a"] = 1 100 | d["b"] # key doesn't exist 101 | except KeyError: 102 | pass 103 | 104 | 105 | Rolling Back Changes 106 | '''''''''''''''''''' 107 | 108 | Suppressing events after an error may not be enough. You can pass :func:`~spectate.events.rollback` 109 | an ``undo`` function which gives you a chances to analyze the events in order to determine 110 | and then return a model to its original state. Any events that you might produce while 111 | modifying a model within the ``undo`` function will be :ref:`muted `. 112 | 113 | .. code-block:: python 114 | 115 | d = mvc.Dict() 116 | 117 | def undo_dict_changes(model, events, error): 118 | seen = set() 119 | for e in reversed(events): 120 | if e.old is mvc.Undefined: 121 | del model[e.key] 122 | else: 123 | model[e.key] = e.old 124 | 125 | try: 126 | with mvc.rollback(d, undo=undo_dict_changes): 127 | d["a"] = 1 128 | d["b"] = 2 129 | print(d) 130 | d["c"] 131 | except KeyError: 132 | pass 133 | print(d) 134 | 135 | .. code-block:: text 136 | 137 | {'a': 1, 'b': 2} 138 | {} 139 | 140 | 141 | Muting Events 142 | ------------- 143 | 144 | If you are setting a default state, or returning to one, it may be useful to withhold 145 | events completely. This one's pretty simple compared to the context managers above. 146 | Just use :func:`~spectate.events.mute` and within its context, no events will 147 | be distributed: 148 | 149 | .. code-block:: python 150 | 151 | from spectate import mvc 152 | 153 | l = mvc.List() 154 | 155 | @mvc.view(l) 156 | def raises(events): 157 | # this won't ever happen 158 | raise ValueError("Events occured!") 159 | 160 | with mvc.mute(l): 161 | l.append(1) 162 | 163 | 164 | Manually Notifying 165 | ------------------ 166 | 167 | At times, and more likely when writing tests, you may need to forcefully send an event 168 | to a model. This can be achieved using the :func:`~spectate.base.notifier` context 169 | manager which provides a ``notify()`` function identical to the one seen in 170 | :ref:`Control Callbacks`. 171 | 172 | .. warning:: 173 | 174 | While you could use :func:`~spectate.base.notifier` instead of adding 175 | :ref:`Adding Model Controls` to your custom models, this is generall discouraged 176 | because the resulting implementation is resistent to extension in subclasses. 177 | 178 | .. code-block:: python 179 | 180 | from spectate import mvc 181 | 182 | m = mvc.Model() 183 | 184 | @mvc.view(m) 185 | def printer(m, events): 186 | for e in events: 187 | print(e) 188 | 189 | with mvc.notifier(m) as notify: 190 | # the view should print out this event 191 | notify(x=1, y=2) 192 | -------------------------------------------------------------------------------- /docs/source/usage/spectate-in-traitlets.rst: -------------------------------------------------------------------------------- 1 | Spectate in Traitlets 2 | ===================== 3 | 4 | The inspiration for Spectate originally came from difficulties encountered while working 5 | with mutable data types in `IPython's Traitlets `__. 6 | Unfortunately Traitlets does not natively allows you to track changes to mutable data 7 | types. 8 | 9 | Now though, with Spectate, we can add this functionality to traitlets using a custom 10 | ``TraitType`` that can act as a base class for all mutable traits. 11 | 12 | .. code-block:: 13 | 14 | from spectate import mvc 15 | from traitlets import TraitType 16 | 17 | 18 | class Mutable(TraitType): 19 | """A base class for mutable traits using Spectate""" 20 | 21 | # Overwrite this in a subclass. 22 | _model_type = None 23 | 24 | # The event type observers must track to spectate changes to the model 25 | _event_type = "mutation" 26 | 27 | # You can dissallow attribute assignment to avoid discontinuities in the 28 | # knowledge observers have about the state of the model. Removing the line below 29 | # will enable attribute assignment and require observers to track 'change' 30 | # events as well as 'mutation' events in to avoid such discontinuities. 31 | __set__ = None 32 | 33 | def default(self, obj): 34 | """Create the initial model instance 35 | 36 | The value returned here will be mutated by users of the HasTraits object 37 | it is assigned to. The resulting events will be tracked in the ``callback`` 38 | defined below and distributed to event observers. 39 | """ 40 | model = self._model_type() 41 | 42 | @mvc.view(model) 43 | def callback(model, events): 44 | obj.notify_change( 45 | dict( 46 | self._make_change(model, events), 47 | name=self.name, 48 | type=self._event_type, 49 | ) 50 | ) 51 | 52 | return model 53 | 54 | def _make_change(self, model, events): 55 | """Construct a dictionary describing the change""" 56 | raise NotImplementedError() 57 | 58 | With this in place we can then subclass our base ``Mutable`` class and use it to create 59 | a ``MutableDict``: 60 | 61 | .. code-block:: 62 | 63 | 64 | class MutableDict(Mutable): 65 | """A mutable dictionary trait""" 66 | 67 | _model_type = mvc.Dict 68 | 69 | def _make_change(self, model, events): 70 | old, new = {}, {} 71 | for e in events: 72 | old[e["key"]] = e["old"] 73 | new[e["key"]] = e["new"] 74 | return {"value": model, "old": old, "new": new} 75 | 76 | An example usage of this trait would then look like: 77 | 78 | .. code-block:: 79 | 80 | from traitlets import HasTraits, observe 81 | 82 | 83 | class MyObject(HasTraits): 84 | mutable_dict = MutableDict() 85 | 86 | @observe("mutable_dict", type="mutation") 87 | def track_mutations_from_method(self, change): 88 | print("method observer:", change) 89 | 90 | 91 | def track_mutations_from_function(change): 92 | print("function observer:", change) 93 | 94 | 95 | my_object = MyObject() 96 | my_object.observe(track_mutations_from_function, "mutable_dict", type="mutation") 97 | 98 | 99 | my_object.mutable_dict["x"] = 1 100 | my_object.mutable_dict.update(x=2, y=3) 101 | 102 | .. code-block:: text 103 | 104 | method observer: {'old': {'x': Undefined}, 'new': {'x': 1}, 'name': 'mutable_dict', 'type': 'mutation'} 105 | function observer: {'old': {'x': Undefined}, 'new': {'x': 1}, 'name': 'mutable_dict', 'type': 'mutation'} 106 | method observer: {'old': {'x': 1, 'y': Undefined}, 'new': {'x': 2, 'y': 3}, 'name': 'mutable_dict', 'type': 'mutation'} 107 | function observer: {'old': {'x': 1, 'y': Undefined}, 'new': {'x': 2, 'y': 3}, 'name': 'mutable_dict', 'type': 'mutation'} 108 | -------------------------------------------------------------------------------- /docs/source/usage/spectating-other-types.rst: -------------------------------------------------------------------------------- 1 | Spectating Other Types 2 | ====================== 3 | 4 | In a prior example demonstrating how to create :ref:`a custom model ` we 5 | used :func:`~spectate.base.notifier` to produce events. This is sufficient in most cases, 6 | but sometimes you aren't able to manually trigger events from within a method. This might 7 | occur when inheriting from a builtin type (e.g. ``list``, ``dict``, etc) that is 8 | implemented in C or a third party package that doesn't use ``spectate``. In those cases, 9 | you must wrap an existing method and are religated to producing events before and/or 10 | after it gets called. 11 | 12 | In these scenarios you must define a :class:`~spectate.base.Model` subclass which has 13 | :class:`~spectate.base.Control` objects assigned to it. Each control object is 14 | responsible for observing calls to particular methods on the model class. For example, 15 | if you wanted to know when an element was appended to a list you might observe the 16 | ``append`` method. 17 | 18 | To show how this works we will implement a simple counter with the goal of knowing when 19 | the value in the counter has incremented or decremented. To get started we should create 20 | a ``Counter`` class which inherits from :class:`~spectate.base.Model` and define 21 | its ``increment`` and ``decrement`` methods normally: 22 | 23 | .. note:: 24 | 25 | Usually if you're using :class:`~spectate.base.Control` objects you'd do it with 26 | `multiple inheritance `__, 27 | but to keep things simple we aren't doing that in the following examples. 28 | 29 | .. code-block:: python 30 | 31 | from spectate import mvc 32 | 33 | class Counter(mvc.Model): 34 | 35 | def __init__(self): 36 | self.value = 0 37 | 38 | def increment(self, amount): 39 | self.value += amount 40 | 41 | def decrement(self, amount): 42 | self.value -= amount 43 | 44 | .. code-block:: python 45 | 46 | c = Counter() 47 | c.increment(1) 48 | c.increment(1) 49 | c.decrement(1) 50 | assert c.value == 1 51 | 52 | 53 | Adding Model Controls 54 | --------------------- 55 | 56 | Because we know that the value within the ``Counter`` changes whenever ``increment`` or 57 | ``decrement`` is called these are the methods that we must observe in order to determine 58 | whether, and by how much it changes. Do do this we should add a :class:`~spectate.base.Control` 59 | to the ``Counter`` and pass in the names of the methods it should be tracking. 60 | 61 | .. code-block:: python 62 | 63 | from spectate import mvc 64 | 65 | class Counter(mvc.Model): 66 | 67 | def __init__(self): 68 | self.value = 0 69 | 70 | def increment(self, amount): 71 | self.value += amount 72 | 73 | def decrement(self, amount): 74 | self.value -= amount 75 | 76 | _control_change = mvc.Control('increment', 'decrement') 77 | 78 | We define the behavior of ``_control_change`` with methods that are triggered before 79 | and/or after the ones being observed. We register these with 80 | :meth:`Control.before() <~spectate.base.Control.before>` 81 | and :meth:`Control.after() <~spectate.base.Control.after>`. For now our 82 | beforeback and afterback will just contain print statements so we can see what they 83 | receive when they are called. 84 | 85 | .. code-block:: python 86 | 87 | from spectate import mvc 88 | 89 | class Counter(mvc.Model): 90 | 91 | def __init__(self): 92 | self.value = 0 93 | 94 | def increment(self, amount): 95 | self.value += amount 96 | 97 | def decrement(self, amount): 98 | self.value -= amount 99 | 100 | _control_change = mvc.Control( 101 | ["increment", "decrement"], 102 | before="_before_change", 103 | after="_after_change", 104 | ) 105 | 106 | def _before_change(self, call, notify): 107 | print("BEFORE") 108 | print(call) 109 | print(notify) 110 | print() 111 | return "result-from-before" 112 | 113 | def _after_change(self, answer, notify): 114 | print("AFTER") 115 | print(answer) 116 | print(notify) 117 | print() 118 | 119 | No lets see what happens we can call ``increment`` or ``decrement``: 120 | 121 | .. code-block:: python 122 | 123 | c = Counter() 124 | c.increment(1) 125 | c.decrement(1) 126 | 127 | .. code-block:: text 128 | 129 | BEFORE 130 | {'name': 'increment', 'kwargs': {}, 'args': (1,), 'parameters': .beforeback..parameters at 0x7f9ce57e8a60>} 131 | .beforeback..notify at 0x7f9ce57e89d8> 132 | 133 | AFTER 134 | {'before': 'result-from-before', 'name': 'increment'} 135 | .afterback..notify at 0x7f9ce57e89d8> 136 | 137 | BEFORE 138 | {'name': 'decrement', 'kwargs': {}, 'args': (1,), 'parameters': .beforeback..parameters at 0x7f9ce57f2400>} 139 | .beforeback..notify at 0x7f9ce57e89d8> 140 | 141 | AFTER 142 | {'before': 'result-from-before', 'name': 'decrement'} 143 | .afterback..notify at 0x7f9ce57e89d8> 144 | 145 | 146 | Control Callbacks 147 | ----------------- 148 | 149 | The callback pair we registered to our ``Counter`` when learning how to 150 | :ref:`define controls `, hereafter referred to as 151 | :ref:`"beforebacks" ` and :ref:`"afterbacks" ` 152 | are how event information is communicated to views. Defining both a beforeback and 153 | an afterback is not required, but doing so allows for a beforeback to pass data to its 154 | corresponding afterback which in turn makes it possible to compute the difference 155 | between the state before and the state after a change takes place: 156 | 157 | .. code-block:: python 158 | 159 | from spectate import mvc 160 | 161 | class Counter(mvc.Model): 162 | 163 | def __init__(self): 164 | self.value = 0 165 | 166 | def increment(self, amount): 167 | self.value += amount 168 | 169 | def decrement(self, amount): 170 | self.value -= amount 171 | 172 | _control_change = mvc.Control( 173 | ["increment", "decrement"], 174 | before="_before_change", 175 | after="_after_change", 176 | ) 177 | 178 | def _before_change(self, call, notify): 179 | amount = call.parameters()["amount"] 180 | print(f"value will {call['name']} by {amount}") 181 | old_value = self.value 182 | return old_value 183 | 184 | def _after_change(self, answer, notify): 185 | old_value = answer["before"] # this was returned by `_before_change` 186 | new_value = self.value 187 | print(f"the old value was {old_value}) 188 | print(f"the new value is {new_value}) 189 | print(f"the value changed by {new_value - old_value}") 190 | 191 | Now we can try incrementing and decrementing as before: 192 | 193 | .. code-block:: python 194 | 195 | c = Counter() 196 | c.increment(1) 197 | c.decrement(1) 198 | 199 | .. code-block:: text 200 | 201 | value will increment by 1 202 | the old value was 0 203 | the new value is 1 204 | the value changed by 1 205 | value will decrement by 1 206 | the old value was 1 207 | the new value is 0 208 | the value changed by -1 209 | 210 | 211 | Control Event Notifications 212 | --------------------------- 213 | 214 | We're now able to use :ref:`"beforebacks" ` and 215 | :ref:`"afterbacks" ` to print out information about a model before 216 | and after a change occures, but what we actually want is to send this same information to 217 | :func:`views ` as we did when we learned :ref:`the basics`. 218 | To accomplish this we use the ``notify`` function passed into the beforeback and 219 | afterback and pass it keyword parameters that can be consumed by views. To keep 220 | things simple we'll just replace our ``print`` statements with calls to ``notify``: 221 | 222 | .. code-block:: python 223 | 224 | from spectate import mvc 225 | 226 | class Counter(mvc.Model): 227 | 228 | def __init__(self): 229 | self.value = 0 230 | 231 | def increment(self, amount): 232 | self.value += amount 233 | 234 | def decrement(self, amount): 235 | self.value -= amount 236 | 237 | _control_change = ( 238 | mvc.Control('increment', 'decrement') 239 | .before("_before_change") 240 | .after("_after_change") 241 | ) 242 | 243 | def _before_change(self, call, notify): 244 | amount = call.parameters()["amount"] 245 | notify(message="value will %s by %s" % (call["name"], amount)) 246 | old_value = self.value 247 | return old_value 248 | 249 | def _after_change(self, answer, notify): 250 | old_value = answer["before"] # this was returned by `_before_change` 251 | new_value = self.value 252 | notify(message="the old value was %r" % old_value) 253 | notify(message="the new value is %r" % new_value) 254 | notify(message="the value changed by %r" % (new_value - old_value)) 255 | 256 | To print out the same messages as before we'll need to register a view with out counter: 257 | 258 | .. code-block:: python 259 | 260 | c = Counter() 261 | 262 | @mvc.view(c) 263 | def print_messages(c, events): 264 | for e in events: 265 | print(e["message"]) 266 | 267 | c.increment(1) 268 | c.decrement(1) 269 | 270 | .. code-block:: text 271 | 272 | value will increment by 1 273 | the old value was 0 274 | the new value is 1 275 | the value changed by 1 276 | value will decrement by 1 277 | the old value was 1 278 | the new value is 0 279 | the value changed by -1 280 | 281 | 282 | Control Beforebacks 283 | ------------------- 284 | 285 | Have a signature of ``(call, notify) -> before`` 286 | 287 | + ``call`` is a ``dict`` with the keys 288 | 289 | + ``'name'`` - the name of the method which was called 290 | 291 | + ``'args'`` - the arguments which that method will call 292 | 293 | + ``'kwargs'`` - the keywords which tCallbacks are registered to specific methods in 294 | pairs - one will be triggered before, and the other after, a call to that method 295 | is made. These two callbacks are referred to as "beforebacks" and "afterbacks" 296 | respectively. Defining both a beforeback and an afterback in each pair is not 297 | required, but doing so allows a beforeback to pass data to its corresponding 298 | afterback. 299 | 300 | + ``parameters`` a function which returns a dictionary where the ``args`` and ``kwargs`` 301 | passed to the method have been mapped to argument names. This won't work for builtin 302 | method like :meth:`dict.get` since they're implemented in C. 303 | 304 | + ``notify`` is a function which will distribute an event to :func:`views ` 305 | 306 | + ``before`` is a value which gets passed on to its respective :ref:`afterback `. 307 | 308 | 309 | Control Afterbacks 310 | ------------------ 311 | 312 | Have a signature of ``(answer, notify)`` 313 | 314 | + ``answer`` is a ``dict`` with the keys 315 | 316 | + ``'name'`` - the name of the method which was called 317 | 318 | + ``'value'`` - the value returned by the method 319 | 320 | + ``'before'`` - the value returned by the respective beforeback 321 | 322 | + ``notify`` is a function which will distribute an event to :func:`views ` 323 | -------------------------------------------------------------------------------- /docs/source/usage/the-basics.rst: -------------------------------------------------------------------------------- 1 | The Basics 2 | ========== 3 | 4 | Spectate defines three main constructs: 5 | 6 | 1. :class:`models ` - objects which get modified by the user. 7 | 8 | 2. :func:`views ` - functions which receives change events. 9 | 10 | 3. :class:`controls ` - private attributes of a model which produces change events. 11 | 12 | Since the :mod:`mvc ` module already provides some basic models for us you 13 | don't need to worry about :class:`controls ` yet. Let's begin 14 | by considering a builtin :class:`~spectate.models.Dict` model. We can instantiate 15 | this object just as we would with a standard :class:`dict`: 16 | 17 | .. code-block:: python 18 | 19 | from spectate import mvc 20 | 21 | d = mvc.Dict(a=0) 22 | 23 | Now though, we can now register a :func:`~spectate.base.view` function with a 24 | decorator. This view function is called any time a change is made to the model ``d`` 25 | that causes its data to be mutated. 26 | 27 | .. code-block:: python 28 | 29 | @mvc.view(d) # <----- pass `d` in the decorator to observe its changes 30 | def printer( 31 | model, # <------- The model which experienced an event 32 | events, # <----- A tuple of event dictionaries 33 | ): 34 | print("model:", model) 35 | for e in events: 36 | print("event:", e) 37 | 38 | Change events are passed into this function as a tuple of immutable dict-like objects 39 | containing change information. Each model has its own change event information. 40 | In the case of a :class:`~spectate.models.Dict` the event objects have the fields 41 | ``key``, ``old``, and ``new``. So when we change a key in ``d`` we'll find that our 42 | ``printer`` view function is called and that it prints out an event object with the 43 | expected information: 44 | 45 | .. code-block:: python 46 | 47 | d["a"] = 1 48 | 49 | .. code-block:: text 50 | 51 | model: {'a': 1} 52 | event: {'key': 'a', 'old': 0, 'new': 1} 53 | 54 | In cases where a mutation would result in changes to multiple change, one or more event 55 | objects can be broadcast to the view function: 56 | 57 | .. code-block:: python 58 | 59 | d.update(b=2, c=3) 60 | 61 | .. code-block:: text 62 | 63 | model: {'a': 1, 'b': 2, 'c': 3} 64 | event: {'key': 'b', 'old': Undefined, 'new': 2} 65 | event: {'key': 'c', 'old': Undefined, 'new': 3} 66 | 67 | 68 | Nesting Models 69 | -------------- 70 | 71 | What if we want to observe changes to nested data structures though? Thankfuly 72 | all of Spectate's :ref:`Builtin Model Types` that inherit from 73 | :class:`~spectate.models.Structure` can handle this automatically whenevener 74 | another model is placed inside another: 75 | 76 | .. code-block:: python 77 | 78 | from spectate import mvc 79 | 80 | outer_dict = mvc.Dict() 81 | inner_dict = mvc.Dict() 82 | 83 | mvc.view(outer_dict, printer) 84 | 85 | outer_dict["x"] = inner_dict 86 | inner_dict["y"] = 1 87 | 88 | .. code-block:: text 89 | 90 | model: {'x': {}} 91 | event: {'key': 'x', 'old': Undefined, 'new': {}} 92 | model: {'y': 1} 93 | event: {'key': 'y', 'old': Undefined, 'new': 1} 94 | 95 | This works just as well if you mix data types too: 96 | 97 | .. code-block:: python 98 | 99 | from spectate import mvc 100 | 101 | outer_dict = mvc.Dict() 102 | middle_list = mvc.List() 103 | inner_obj = mvc.Object() 104 | 105 | mvc.view(outer_dict, printer) 106 | 107 | outer_dict["x"] = middle_list 108 | middle_list.append(inner_obj) 109 | inner_obj.y = 1 110 | 111 | .. code-block:: text 112 | 113 | model: {'x': []} 114 | event: {'key': 'x', 'old': Undefined, 'new': []} 115 | model: [] 116 | event: {'index': 0, 'old': Undefined, 'new': } 117 | model: 118 | event: {'attr': 'y', 'old': Undefined, 'new': 1} 119 | 120 | However, note that events on nested data structures don't carry information about the 121 | location of the notifying model. For this you'll need to implement a :ref:`Custom Models` 122 | and add this information to the events manually. 123 | 124 | 125 | Custom Models 126 | ------------- 127 | 128 | To create a custom model all you have to do is inherit from :class:`~spectate.base.Model` 129 | and broadcast events with a :func:`~spectate.base.notifier`. To get the idea across, 130 | lets implement a simple counter object that notifies when a value is incremented or 131 | decremented. 132 | 133 | .. code-block:: 134 | 135 | from spectate import mvc 136 | 137 | class Counter(mvc.Model): 138 | 139 | def __init__(self): 140 | self.value = 0 141 | 142 | def increment(self): 143 | self.value += 1 144 | with mvc.notifier(self) as notify: 145 | notify(new=self.value) 146 | 147 | def decrement(self): 148 | self.value -= 1 149 | with mvc.notifier(self) as notify: 150 | notify(new=self.value) 151 | 152 | counter = Counter() 153 | 154 | @mvc.view(counter) 155 | def printer(model, events): 156 | for e in events: 157 | print(e) 158 | 159 | counter.increment() 160 | counter.increment() 161 | counter.decrement() 162 | 163 | .. code-block:: 164 | 165 | {'new': 1} 166 | {'new': 2} 167 | {'new': 1} 168 | 169 | To share or unshare the view functions between two models using the 170 | :func:`~spectate.base.link` and :func:`~spectate.base.unlink` functions respectively. 171 | This is especially useful when creating nested data structures. For example we can 172 | use it to create an observable binary tree: 173 | 174 | .. code-block:: 175 | 176 | class Node(mvc.Model): 177 | 178 | def __init__(self, data, parent=None): 179 | if parent is not None: 180 | mvc.link(parent, self) 181 | self.parent = parent 182 | self.left = None 183 | self.right = None 184 | self.data = data 185 | 186 | def add(self, data): 187 | if data <= self.data: 188 | if self.left is None: 189 | self.left = Node(data, self) 190 | with mvc.notifier(self) as notify: 191 | notify(left=self.left, path=self.path()) 192 | else: 193 | self.left.add(data) 194 | else: 195 | if self.right is None: 196 | self.right = Node(data, self) 197 | with mvc.notifier(self) as notify: 198 | notify(right=self.right, path=self.path()) 199 | else: 200 | self.right.add(data) 201 | 202 | def path(self): 203 | n = self 204 | path = [] 205 | while n is not None: 206 | path.insert(0, n) 207 | n = n.parent 208 | return path 209 | 210 | def __repr__(self): 211 | return f"Node({self.data})" 212 | 213 | root = Node(0) 214 | 215 | mvc.view(root, printer) 216 | 217 | root.add(1) 218 | root.add(0) 219 | root.add(5) 220 | root.add(2) 221 | root.add(4) 222 | root.add(3) 223 | 224 | 225 | .. code-block:: text 226 | 227 | model: Node(0) 228 | event: {'right': Node(1), 'path': [Node(0)]} 229 | model: Node(0) 230 | event: {'left': Node(0), 'path': [Node(0)]} 231 | model: Node(1) 232 | event: {'right': Node(5), 'path': [Node(0), Node(1)]} 233 | model: Node(5) 234 | event: {'left': Node(2), 'path': [Node(0), Node(1), Node(5)]} 235 | model: Node(2) 236 | event: {'right': Node(4), 'path': [Node(0), Node(1), Node(5), Node(2)]} 237 | model: Node(4) 238 | event: {'left': Node(3), 'path': [Node(0), Node(1), Node(5), Node(2), Node(4)]} 239 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Distribution 2 | # ============ 3 | twine 4 | wheel 5 | cmarkgfm 6 | 7 | # Documentation 8 | # ============= 9 | furo 10 | sphinx-autodoc-typehints 11 | 12 | # Testing 13 | # ======= 14 | pytest 15 | flake8 16 | pytest-cov 17 | mypy 18 | black 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F811, N802 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9,N 6 | exclude = 7 | .eggs/* 8 | .tox/* 9 | 10 | [tool:pytest] 11 | testpaths = tests 12 | xfail_strict = True 13 | addopts = --cov=spectate --cov-report term 14 | 15 | [coverage:report] 16 | fail_under = 92 17 | show_missing = True 18 | skip_covered = True 19 | sort = Name 20 | exclude_lines = 21 | pragma: no cover 22 | \.\.\. 23 | raise NotImplementedError 24 | 25 | [build_sphinx] 26 | all-files = true 27 | source-dir = docs/source 28 | build-dir = docs/build 29 | 30 | [bdist_wheel] 31 | universal=1 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import find_packages 4 | from distutils.core import setup 5 | 6 | name = "spectate" 7 | 8 | # paths used to gather files 9 | here = os.path.abspath(os.path.dirname(__file__)) 10 | pkg_root = os.path.join(here, name) 11 | 12 | # ----------------------------------------------------------------------------- 13 | # Package Basics 14 | # ----------------------------------------------------------------------------- 15 | 16 | package = dict( 17 | name=name, 18 | license="MIT", 19 | packages=find_packages(exclude=["tests*"]), 20 | python_requires=">=3.6", 21 | description="Track changes to mutable data types.", 22 | classifiers=["Intended Audience :: Developers"], 23 | author="Ryan Morshead", 24 | author_email="ryan.morshead@gmail.com", 25 | url="https://github.com/rmorshea/spectate", 26 | keywords=["eventful", "callbacks", "mutable", "MVC", "model", "view", "controller"], 27 | platforms="Linux, Mac OS X, Windows", 28 | include_package_data=True, 29 | ) 30 | 31 | # ----------------------------------------------------------------------------- 32 | # Library Version 33 | # ----------------------------------------------------------------------------- 34 | 35 | with open(os.path.join(pkg_root, "__init__.py")) as f: 36 | for line in f.read().split("\n"): 37 | if line.startswith("__version__ = "): 38 | package["version"] = eval(line.split("=", 1)[1]) 39 | break 40 | else: 41 | print("No version found in %s/__init__.py" % pkg_root) 42 | sys.exit(1) 43 | 44 | # ----------------------------------------------------------------------------- 45 | # Library Description 46 | # ----------------------------------------------------------------------------- 47 | 48 | package["long_description_content_type"] = "text/markdown" 49 | with open(os.path.join(here, "README.md")) as f: 50 | package["long_description"] = f.read() 51 | 52 | # ----------------------------------------------------------------------------- 53 | # Install It 54 | # ----------------------------------------------------------------------------- 55 | 56 | if __name__ == "__main__": 57 | setup(**package) 58 | -------------------------------------------------------------------------------- /spectate/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.1" # evaluated in setup.py 2 | -------------------------------------------------------------------------------- /spectate/base.py: -------------------------------------------------------------------------------- 1 | from inspect import signature 2 | from functools import wraps 3 | from typing import ( 4 | Any, 5 | Union, 6 | Callable, 7 | Optional, 8 | TypeVar, 9 | Tuple, 10 | Dict, 11 | List, 12 | Iterator, 13 | overload, 14 | ) 15 | from contextlib import contextmanager 16 | from weakref import WeakValueDictionary 17 | 18 | 19 | __all__ = ["Model", "Control", "view", "unview", "views", "link", "unlink", "notifier"] 20 | 21 | Event = Dict[str, Any] 22 | TupleOfEvents = Tuple[Event, ...] 23 | ViewFunction = Callable[["Model", TupleOfEvents], None] 24 | 25 | 26 | def views(model: "Model") -> List[ViewFunction]: 27 | """Return a model's views keyed on what events they respond to. 28 | 29 | Model views are added by calling :func:`view` on a model. 30 | """ 31 | if not isinstance(model, Model): 32 | raise TypeError("Expected a Model, not %r." % model) 33 | return model._model_views[:] 34 | 35 | 36 | _F = TypeVar("_F", bound=ViewFunction) 37 | 38 | 39 | @overload 40 | def view(model: "Model") -> Callable[[_F], _F]: 41 | ... 42 | 43 | 44 | @overload 45 | def view(model: "Model", function: ViewFunction) -> None: 46 | ... 47 | 48 | 49 | def view( 50 | model: "Model", function: Optional[ViewFunction] = None 51 | ) -> Optional[Callable[[_F], _F]]: 52 | """A decorator for registering a callback to a model 53 | 54 | Parameters: 55 | model: the model object whose changes the callback should respond to. 56 | 57 | Examples: 58 | .. code-block:: python 59 | 60 | from spectate import mvc 61 | 62 | items = mvc.List() 63 | 64 | @mvc.view(items) 65 | def printer(items, events): 66 | for e in events: 67 | print(e) 68 | 69 | items.append(1) 70 | """ 71 | if not isinstance(model, Model): 72 | raise TypeError("Expected a Model, notself._model_notifier() %r." % model) 73 | 74 | def setup(function: _F) -> _F: 75 | model._attach_model_view(function) 76 | return function 77 | 78 | if function is not None: 79 | setup(function) 80 | return None 81 | else: 82 | return setup 83 | 84 | 85 | def unview(model: "Model", function: ViewFunction) -> None: 86 | """Remove a view callbcak from a model. 87 | 88 | Parameters: 89 | model: The model which contains the view function. 90 | function: The callable which was registered to the model as a view. 91 | 92 | Raises: 93 | ValueError: If the given ``function`` is not a view of the given ``model``. 94 | """ 95 | model._remove_model_view(function) 96 | 97 | 98 | def link(source: "Model", *targets: "Model") -> None: 99 | """Attach all of the source's present and future view functions to the targets. 100 | 101 | Parameters: 102 | source: The model whose view functions will be attached to the targets. 103 | targets: The models that will acquire the source's view functions. 104 | """ 105 | for t in targets: 106 | source._attach_child_model(t) 107 | 108 | 109 | def unlink(source: "Model", *targets: "Model") -> None: 110 | """Remove all of the source's present and future view functions from the targets. 111 | 112 | Parameters: 113 | source: The model whose view functions will be removed from the targets. 114 | targets: The models that will no longer share view functions with the source. 115 | """ 116 | for t in targets: 117 | source._remove_child_model(t) 118 | 119 | 120 | @contextmanager 121 | def notifier(model: "Model") -> Iterator[Callable[..., None]]: 122 | """Manually send notifications to the given model. 123 | 124 | Parameters: 125 | model: The model whose views will recieve notifications 126 | 127 | Returns: 128 | A function whose keyword arguments become event data. 129 | 130 | Example: 131 | 132 | .. code-block:: python 133 | 134 | m = Model() 135 | 136 | @view(m) 137 | def printer(m, events): 138 | for e in events: 139 | print(e) 140 | 141 | with notifier(m) as notify: 142 | # the view should print out this event 143 | notify(x=1, y=2) 144 | """ 145 | events = [] 146 | 147 | def notify(*args, **kwargs): 148 | events.append(dict(*args, **kwargs)) 149 | 150 | yield notify 151 | 152 | if events: 153 | model._notify_model_views(tuple(events)) 154 | 155 | 156 | class Control: 157 | """An object used to define control methods on a :class:`Model` 158 | 159 | A "control" method on a :class:`Model` is one which reacts to another method being 160 | called. For example there is a control method on the 161 | :class:`~spectate.mvc.models.List` 162 | which responds when :meth:`~spectate.mvc.models.List.append` is called. 163 | 164 | A control method is a slightly modified :ref:`beforeback ` or 165 | :ref:`afterback ` that accepts an extra ``notify`` argument. 166 | These are added to a control object by calling :meth:`Control.before` or 167 | :meth:`Control.after` respectively. The ``notify`` arugment is a function which 168 | allows a control method to send messages to :func:`views ` that are registered 169 | to a :class:`Model`. 170 | 171 | Parameters: 172 | methods: 173 | The names of the methods on the model which this control will react to 174 | When they are calthrough the Nodeled. This is either a comma seperated 175 | string, or a list of strings. 176 | before: 177 | A control method that reacts before any of the given ``methods`` are 178 | called. If given as a callable, then that function will be used as the 179 | callback. If given as a string, then the control will look up a method 180 | with that name when reacting (useful when subclassing). 181 | after: 182 | A control method that reacts after any of the given ``methods`` are 183 | alled. If given as a callable, then that function will be used as the 184 | callback. If given as a string, then the control will look up a method 185 | with that name when reacting (useful when subclassing). 186 | 187 | Examples: 188 | Control methods are registered to a :class:`Control` with a ``str`` or function. 189 | A string may refer to the name of a method on a `Model` while a function should 190 | be decorated under the same name as the :class:`Control` object to preserve the 191 | namespace. 192 | 193 | .. code-block:: python 194 | 195 | from spectate import mvc 196 | 197 | class X(mvc.Model): 198 | 199 | _control_method = mvc.Control("method").before("_control_before_method") 200 | 201 | def _control_before_method(self, call, notify): 202 | print("before") 203 | 204 | # Note how the method uses the same name. It 205 | # would be redundant to use a different one. 206 | @_control_a.after 207 | def _control_method(self, answer, notify): 208 | print("after") 209 | 210 | def method(self): 211 | print("during") 212 | 213 | x = X() 214 | x.method() 215 | 216 | .. code-block:: text 217 | 218 | before 219 | during 220 | after 221 | """ 222 | 223 | def __init__( 224 | self, 225 | methods: Union[list, tuple, str], 226 | *, 227 | before: Union[Callable, str] = None, 228 | after: Union[Callable, str] = None, 229 | ): 230 | if isinstance(methods, (list, tuple)): 231 | self.methods = tuple(methods) 232 | elif isinstance(methods, str): 233 | self.methods = tuple(map(str.strip, methods.split(","))) 234 | else: 235 | raise ValueError("methods must be a string or list of strings") 236 | self.name = None 237 | if isinstance(before, Control): 238 | before = before._before 239 | self._before = before 240 | if isinstance(after, Control): 241 | after = after._after 242 | self._after = after 243 | 244 | def __get__(self, obj, cls): 245 | if obj is None: 246 | return self 247 | else: 248 | return BoundControl(obj, self) 249 | 250 | def __set_name__(self, cls, name): 251 | if not issubclass(cls, Model): 252 | raise TypeError("Can only define a control on a Model, not %r" % cls) 253 | if self.name: 254 | msg = "Control was defined twice - %r and %r." 255 | raise RuntimeError(msg % (self.name, name)) 256 | else: 257 | self.name = name 258 | for m in self.methods: 259 | setattr(cls, m, self._create_controlled_method(cls, m)) 260 | 261 | def _create_controlled_method(self, cls, name): 262 | method = getattr(cls, name) 263 | 264 | @wraps(method) 265 | def wrapped_method(obj, *args, **kwargs): 266 | cls = type(obj) 267 | bound_control = self.__get__(obj, cls) 268 | 269 | before_control = bound_control.before 270 | if before_control is not None: 271 | before_value = before_control( 272 | obj, {"name": name, "args": args, "kwargs": kwargs} 273 | ) 274 | else: 275 | before_value = None 276 | 277 | result = method.__get__(obj, cls)(*args, **kwargs) 278 | 279 | after_control = bound_control.after 280 | if after_control is not None: 281 | after_control( 282 | obj, {"before": before_value, "name": name, "value": result} 283 | ) 284 | 285 | return result 286 | 287 | return wrapped_method 288 | 289 | 290 | class BoundControl: 291 | def __init__(self, obj, ctrl): 292 | self._obj = obj 293 | self._cls = type(obj) 294 | self._name = ctrl.name 295 | self._before = ctrl._before 296 | self._after = ctrl._after 297 | self.methods = ctrl.methods 298 | 299 | @property 300 | def before(self): 301 | if self._before is None: 302 | method_name = self._name + "_before" 303 | if hasattr(self._obj, method_name): 304 | before = getattr(self._obj, method_name) 305 | else: 306 | return None 307 | else: 308 | before = self._before 309 | 310 | if isinstance(before, str): 311 | before = getattr(self._obj, before) 312 | elif hasattr(before, "__get__"): 313 | before = before.__get__(self._obj, type(self._obj)) 314 | 315 | @wraps(before) 316 | def beforeback(value, call): 317 | def parameters(): 318 | meth = getattr(value, call["name"]) 319 | bound = signature(meth).bind(*call["args"], **call["kwargs"]) 320 | return dict(bound.arguments) 321 | 322 | with notifier(value) as notify: 323 | return before(dict(call, parameters=parameters), notify) 324 | 325 | return beforeback 326 | 327 | @property 328 | def after(self): 329 | if self._after is None: 330 | return None 331 | else: 332 | after = self._after 333 | 334 | if isinstance(after, str): 335 | after = getattr(self._obj, after) 336 | elif hasattr(after, "__get__"): 337 | after = after.__get__(self._obj, type(self._obj)) 338 | 339 | @wraps(after) 340 | def afterback(value, answer): 341 | with notifier(value) as notify: 342 | return after(answer, notify) 343 | 344 | return afterback 345 | 346 | 347 | class Model: 348 | """An object that can be :class:`controlled ` and :func:`viewed `. 349 | 350 | Users should define :class:`Control` methods and then :func:`view` the change 351 | events those controls emit. This process starts by defining controls on a subclass 352 | of :class:`Model`. 353 | 354 | Examples: 355 | .. code-block:: python 356 | 357 | from specate import mvc 358 | 359 | class Object(Model): 360 | 361 | _control_attr_change = Control( 362 | "__setattr__, __delattr__", 363 | before="_control_before_attr_change", 364 | after="_control_after_attr_change", 365 | ) 366 | 367 | def __init__(self, *args, **kwargs): 368 | for k, v in dict(*args, **kwargs).items(): 369 | setattr(self, k, v) 370 | 371 | def _control_before_attr_change(self, call, notify): 372 | return call["args"][0], getattr(self, call["args"][0], Undefined) 373 | 374 | def _control_after_attr_change(self, answer, notify): 375 | attr, old = answer["before"] 376 | new = getattr(self, attr, Undefined) 377 | if new != old: 378 | notify(attr=attr, old=old, new=new) 379 | 380 | o = Object() 381 | 382 | @mvc.view(o) 383 | def printer(o, events): 384 | for e in events: 385 | print(e) 386 | """ 387 | 388 | _model_views: List[ViewFunction] 389 | _inner_models: "WeakValueDictionary[int, Model]" 390 | 391 | def __new__(cls, *args: Any, **kwargs: Any) -> "Model": 392 | new = super().__new__ 393 | if new is not object.__new__: 394 | self = new(cls, *args, **kwargs) # type: ignore 395 | else: 396 | self = new(cls) 397 | 398 | object.__setattr__(self, "_model_views", []) 399 | object.__setattr__(self, "_inner_models", WeakValueDictionary()) 400 | 401 | return self 402 | 403 | def _attach_child_model(self, model: "Model") -> None: 404 | self._inner_models[id(model)] = model 405 | for v in self._model_views: 406 | model._attach_model_view(v) 407 | 408 | def _remove_child_model(self, model: "Model") -> None: 409 | try: 410 | del self._inner_models[id(model)] 411 | except KeyError: 412 | pass 413 | else: 414 | for v in self._model_views: 415 | model._remove_model_view(v) 416 | 417 | def _attach_model_view(self, function: ViewFunction) -> None: 418 | self._model_views.append(function) 419 | for inner in self._inner_models.values(): 420 | inner._attach_model_view(function) 421 | 422 | def _remove_model_view(self, function: ViewFunction) -> None: 423 | self._model_views.remove(function) 424 | for inner in self._inner_models.values(): 425 | inner._remove_model_view(function) 426 | 427 | def _notify_model_views(self, events: TupleOfEvents): 428 | for view in self._model_views: 429 | view(self, events) 430 | -------------------------------------------------------------------------------- /spectate/events.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Iterator, Callable, Optional, List 3 | 4 | from .base import Model, Event, TupleOfEvents 5 | 6 | __all__ = ["hold", "mute", "rollback"] 7 | 8 | 9 | _EventReducerFunc = Callable[[Model, List[Event]], List[Event]] 10 | 11 | 12 | @contextmanager 13 | def hold( 14 | model: Model, reducer: Optional[_EventReducerFunc] = None 15 | ) -> Iterator[List[Event]]: 16 | """Temporarilly withold change events in a modifiable list. 17 | 18 | All changes that are captured within a "hold" context are forwarded to a list 19 | which is yielded to the user before being sent to views of the given ``model``. 20 | If desired, the user may modify the list of events before the context is left in 21 | order to change the events that are ultimately sent to the model's views. 22 | 23 | Parameters: 24 | model: 25 | The model object whose change events will be temporarilly witheld. 26 | reducer: 27 | A function for modifying the events list at the end of the context. 28 | Its signature is ``(model, events) -> new_events`` where ``model`` is the 29 | given model, ``events`` is the complete list of events produced in the 30 | context, and the returned ``new_events`` is a list of events that will 31 | actuall be distributed to views. 32 | 33 | Notes: 34 | All changes witheld from views will be sent as a single notification. For 35 | example if you view a :class:`specate.mvc.models.List` and its ``append()`` 36 | method is called three times within a :func:`hold` context, 37 | 38 | 39 | Examples: 40 | Note how the event from ``l.append(1)`` is omitted from the printed statements. 41 | 42 | .. code-block:: python 43 | 44 | from spectate import mvc 45 | 46 | l = mvc.List() 47 | 48 | mvc.view(d, lambda d, e: list(map(print, e))) 49 | 50 | with mvc.hold(l) as events: 51 | l.append(1) 52 | l.append(2) 53 | 54 | del events[0] 55 | 56 | .. code-block:: text 57 | 58 | {'index': 1, 'old': Undefined, 'new': 2} 59 | """ 60 | if not isinstance(model, Model): 61 | raise TypeError("Expected a Model, not %r." % model) 62 | events: List[Event] = [] 63 | restore = model.__dict__.get("_notify_model_views") 64 | model._notify_model_views = lambda e: events.extend(e) # type: ignore 65 | 66 | try: 67 | yield events 68 | finally: 69 | if restore is None: 70 | del model._notify_model_views 71 | else: 72 | model._notify_model_views = restore # type: ignore 73 | 74 | if reducer is not None: 75 | events = reducer(model, events) 76 | 77 | model._notify_model_views(tuple(events)) 78 | 79 | 80 | @contextmanager 81 | def rollback( 82 | model: Model, 83 | undo: Callable[[Model, TupleOfEvents, Exception], None] = None, 84 | reducer: Optional[_EventReducerFunc] = None, 85 | ) -> Iterator[None]: 86 | """Withold events if an error occurs. 87 | 88 | Generall operate 89 | 90 | Parameters: 91 | model: 92 | The model object whose change events may be witheld. 93 | undo: 94 | An optional function for reversing any changes that may have taken place. 95 | Its signature is ``(model, events, error)`` where ``model`` is the given 96 | model, ``events`` is a list of all the events that took place, and ``error`` 97 | is the exception that was riased. Any changes that you make to the model 98 | within this function will not produce events. 99 | 100 | Examples: 101 | 102 | Simple supression of events: 103 | 104 | .. code-block:: python 105 | 106 | from spectate import mvc 107 | 108 | d = mvc.Dict() 109 | 110 | @mvc.view(d) 111 | def should_not_be_called(d, events): 112 | # we never call this view 113 | assert False 114 | 115 | try: 116 | with mvc.rollback(d): 117 | d["a"] = 1 118 | d["b"] # key doesn't exist 119 | except KeyError: 120 | pass 121 | 122 | Undo changes for a dictionary: 123 | 124 | .. code-block:: python 125 | 126 | from spectate import mvc 127 | 128 | def undo_dict_changes(model, events, error): 129 | seen = set() 130 | for e in reversed(events): 131 | if e.old is mvc.Undefined: 132 | del model[e.key] 133 | else: 134 | model[e.key] = e.old 135 | 136 | try: 137 | with mvc.rollback(d, undo=undo_dict_changes): 138 | d["a"] = 1 139 | d["b"] = 2 140 | print(d) 141 | d["c"] 142 | except KeyError: 143 | pass 144 | print(d) 145 | 146 | .. code-block:: python 147 | 148 | {'a': 1, 'b': 2} 149 | {} 150 | """ 151 | with hold(model, reducer=reducer) as events: 152 | try: 153 | yield None 154 | except Exception as error: 155 | if undo is not None: 156 | with mute(model): 157 | undo(model, tuple(events), error) 158 | events.clear() 159 | raise 160 | 161 | 162 | @contextmanager 163 | def mute(model: Model) -> Iterator[None]: 164 | """Block a model's views from being notified. 165 | 166 | All changes within a "mute" context will be blocked. No content is yielded to the 167 | user as in :func:`hold`, and the views of the model are never notified that changes 168 | took place. 169 | 170 | Parameters: 171 | mode: The model whose change events will be blocked. 172 | 173 | Examples: 174 | 175 | The view is never called due to the :func:`mute` context: 176 | 177 | .. code-block:: python 178 | 179 | from spectate import mvc 180 | 181 | l = mvc.List() 182 | 183 | @mvc.view(l) 184 | def raises(events): 185 | raise ValueError("Events occured!") 186 | 187 | with mvc.mute(l): 188 | l.append(1) 189 | """ 190 | if not isinstance(model, Model): 191 | raise TypeError("Expected a Model, not %r." % model) 192 | restore = model.__dict__.get("_notify_model_views") 193 | model._notify_model_views = lambda e: None # type: ignore 194 | try: 195 | yield None 196 | finally: 197 | if restore is None: 198 | del model._notify_model_views 199 | else: 200 | model._notify_model_views = restore # type: ignore 201 | -------------------------------------------------------------------------------- /spectate/models.py: -------------------------------------------------------------------------------- 1 | # SEE END OF FILE FOR LICENSE 2 | 3 | import inspect 4 | import itertools 5 | 6 | from .utils import Sentinel 7 | from .base import Model, Control 8 | 9 | 10 | __all__ = ["Structure", "List", "Dict", "Set", "Object", "Undefined"] 11 | 12 | 13 | Undefined = Sentinel("Undefined") 14 | 15 | 16 | class Structure(Model): 17 | def _notify_model_views(self, events): 18 | for e in events: 19 | if "new" in e: 20 | new = e["new"] 21 | if isinstance(new, Model): 22 | self._attach_child_model(new) 23 | if "old" in e: 24 | old = e["old"] 25 | if isinstance(old, Model): 26 | self._remove_child_model(old) 27 | super()._notify_model_views(events) 28 | 29 | 30 | class List(Structure, list): 31 | """A :mod:`spectate.mvc` enabled ``list``.""" 32 | 33 | _control_setitem = Control( 34 | "__setitem__", before="_control_before_setitem", after="_control_after_setitem" 35 | ) 36 | 37 | _control_delitem = Control( 38 | "__delitem__", before="_control_before_delitem", after="_control_after_delitem" 39 | ) 40 | 41 | _control_insert = Control( 42 | "insert", before="_control_before_insert", after="_control_after_insert" 43 | ) 44 | 45 | _control_append = Control("append", after="_control_after_append") 46 | 47 | _control_extend = Control( 48 | "__init__, extend", 49 | before="_control_before_extend", 50 | after="_control_after_extend", 51 | ) 52 | 53 | _control_pop = Control( 54 | "pop", before="_control_before_pop", after="_control_after_delitem" 55 | ) 56 | 57 | _control_clear = Control( 58 | "clear", before="_control_before_clear", after="_control_after_clear" 59 | ) 60 | 61 | _control_remove = Control( 62 | "remove", before="_control_before_remove", after="_control_after_delitem" 63 | ) 64 | 65 | _control_rearrangement = Control( 66 | "sort, reverse", 67 | before="_control_before_rearrangement", 68 | after="_control_after_rearrangement", 69 | ) 70 | 71 | def _control_before_setitem(self, call, notify): 72 | index = call["args"][0] 73 | try: 74 | old = self[index] 75 | except KeyError: 76 | old = Undefined 77 | return index, old 78 | 79 | def _control_after_setitem(self, answer, notify): 80 | index, old = answer["before"] 81 | new = self[index] 82 | if new is not old: 83 | notify(index=index, old=old, new=new) 84 | 85 | def _control_before_delitem(self, call, notify): 86 | index = call["args"][0] 87 | return index, self[index:] 88 | 89 | def _control_after_delitem(self, answer, notify): 90 | index, old = answer["before"] 91 | for i, x in enumerate(old): 92 | try: 93 | new = self[index + i] 94 | except IndexError: 95 | new = Undefined 96 | notify(index=(i + index), old=x, new=new) 97 | 98 | def _control_before_insert(self, call, notify): 99 | index = call["args"][0] 100 | return index, self[index:] 101 | 102 | def _control_after_insert(self, answer, notify): 103 | index, old = answer["before"] 104 | for i in range(index, len(self)): 105 | try: 106 | o = old[i] 107 | except IndexError: 108 | o = Undefined 109 | notify(index=i, old=o, new=self[i]) 110 | 111 | def _control_after_append(self, answer, notify): 112 | notify(index=len(self) - 1, old=Undefined, new=self[-1]) 113 | 114 | def _control_before_extend(self, call, notify): 115 | return len(self) 116 | 117 | def _control_after_extend(self, answer, notify): 118 | for i in range(answer["before"], len(self)): 119 | notify(index=i, old=Undefined, new=self[i]) 120 | 121 | def _control_before_pop(self, call, notify): 122 | if not call["args"]: 123 | index = len(self) - 1 124 | else: 125 | index = call["args"][0] 126 | return index, self[index:] 127 | 128 | def _control_before_clear(self, call, notify): 129 | return self.copy() 130 | 131 | def _control_after_clear(self, answer, notify): 132 | for i, v in enumerate(answer["before"]): 133 | notify(index=i, old=v, new=Undefined) 134 | 135 | def _control_before_remove(self, call, notify): 136 | index = self.index(call["args"][0]) 137 | return index, self[index:] 138 | 139 | def _control_before_rearrangement(self, call, notify): 140 | return self.copy() 141 | 142 | def _control_after_rearrangement(self, answer, notify): 143 | old = answer["before"] 144 | for i, v in enumerate(old): 145 | if v != self[i]: 146 | notify(index=i, old=v, new=self[i]) 147 | 148 | 149 | class Dict(Structure, dict): 150 | """A :mod:`spectate.mvc` enabled ``dict``.""" 151 | 152 | _control_setitem = Control( 153 | "__setitem__, setdefault", 154 | before="_control_before_setitem", 155 | after="_control_after_setitem", 156 | ) 157 | 158 | _control_delitem = Control( 159 | "__delitem__, pop", 160 | before="_control_before_delitem", 161 | after="_control_after_delitem", 162 | ) 163 | 164 | _control_update = Control( 165 | "__init__, update", 166 | before="_control_before_update", 167 | after="_control_after_update", 168 | ) 169 | 170 | _control_clear = Control( 171 | "clear", before="_control_before_clear", after="_control_after_clear" 172 | ) 173 | 174 | def _control_before_setitem(self, call, notify): 175 | key = call["args"][0] 176 | old = self.get(key, Undefined) 177 | return key, old 178 | 179 | def _control_after_setitem(self, answer, notify): 180 | key, old = answer["before"] 181 | new = self[key] 182 | if new != old: 183 | notify(key=key, old=old, new=new) 184 | 185 | def _control_before_delitem(self, call, notify): 186 | key = call["args"][0] 187 | try: 188 | return key, self[key] 189 | except KeyError: 190 | # the base method will error on its own 191 | pass 192 | 193 | def _control_after_delitem(self, answer, notify): 194 | key, old = answer["before"] 195 | notify(key=key, old=old, new=Undefined) 196 | 197 | def _control_before_update(self, call, notify): 198 | if len(call["args"]): 199 | args = call["args"][0] 200 | if inspect.isgenerator(args): 201 | # copy generator so it doesn't get exhausted 202 | args = itertools.tee(args)[1] 203 | new = dict(args) 204 | new.update(call["kwargs"]) 205 | else: 206 | new = call["kwargs"] 207 | old = {k: self.get(k, Undefined) for k in new} 208 | return old 209 | 210 | def _control_after_update(self, answer, notify): 211 | for k, v in answer["before"].items(): 212 | if self[k] != v: 213 | notify(key=k, old=v, new=self[k]) 214 | 215 | def _control_before_clear(self, call, notify): 216 | return self.copy() 217 | 218 | def _control_after_clear(self, answer, notify): 219 | for k, v in answer["before"].items(): 220 | notify(key=k, old=v, new=Undefined) 221 | 222 | 223 | class Set(Structure, set): 224 | """A :mod:`spectate.mvc` enabled ``set``.""" 225 | 226 | _control_update = Control( 227 | [ 228 | "__init__", 229 | "clear", 230 | "update", 231 | "difference_update", 232 | "intersection_update", 233 | "add", 234 | "pop", 235 | "remove", 236 | "symmetric_difference_update", 237 | "discard", 238 | ], 239 | before="_control_before_update", 240 | after="_control_after_update", 241 | ) 242 | 243 | def _control_before_update(self, call, notify): 244 | return self.copy() 245 | 246 | def _control_after_update(self, answer, notify): 247 | new = self.difference(answer["before"]) 248 | old = answer["before"].difference(self) 249 | if new or old: 250 | notify(new=new, old=old) 251 | 252 | 253 | class Object(Structure): 254 | """A :mod:`spectat.mvc` enabled ``object``.""" 255 | 256 | _control_attr_change = Control( 257 | "__setattr__, __delattr__", 258 | before="_control_before_attr_change", 259 | after="_control_after_attr_change", 260 | ) 261 | 262 | def __init__(self, *args, **kwargs): 263 | for k, v in dict(*args, **kwargs).items(): 264 | setattr(self, k, v) 265 | 266 | def _control_before_attr_change(self, call, notify): 267 | return call["args"][0], getattr(self, call["args"][0], Undefined) 268 | 269 | def _control_after_attr_change(self, answer, notify): 270 | attr, old = answer["before"] 271 | new = getattr(self, attr, Undefined) 272 | if new != old: 273 | notify(attr=attr, old=old, new=new) 274 | -------------------------------------------------------------------------------- /spectate/mvc.py: -------------------------------------------------------------------------------- 1 | """A modules which exports specate's Model-View-Controller utilities in a common namespace 2 | 3 | For more info: 4 | 5 | - :mod:`spectate.base` 6 | - :mod:`spectate.events` 7 | - :mod:`spectate.models` 8 | """ 9 | 10 | from .base import * # noqa 11 | from .events import * # noqa 12 | from .models import * # noqa 13 | -------------------------------------------------------------------------------- /spectate/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561 2 | -------------------------------------------------------------------------------- /spectate/utils.py: -------------------------------------------------------------------------------- 1 | class Sentinel: 2 | __slots__ = "_name" 3 | 4 | def __init__(self, name): 5 | self._name = name 6 | 7 | def __repr__(self): 8 | return self._name # pragma: no cover 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmorshea/spectate/2a76399197c06dfaa8a1fd03933abdd81942c157/tests/__init__.py -------------------------------------------------------------------------------- /tests/mock.py: -------------------------------------------------------------------------------- 1 | from spectate import mvc 2 | 3 | 4 | def events_to_comparable_list(events): 5 | return tuple(sorted(tuple(sorted((k, evt[k]) for k in evt)) for evt in events)) 6 | 7 | 8 | def model_events(model, *args, **kwargs): 9 | cached_events = [] 10 | 11 | if not isinstance(model, mvc.Model): 12 | model = model(*args, **kwargs) 13 | 14 | @mvc.view(model) 15 | def cache(model, events): 16 | cached_events.extend(events) 17 | 18 | return model, cached_events 19 | 20 | 21 | class Counter(mvc.Model): 22 | def __init__(self): 23 | self.value = 0 24 | 25 | def increment(self, amount): 26 | self.value += amount 27 | 28 | def decrement(self, amount): 29 | self.value -= amount 30 | 31 | _control_change = mvc.Control( 32 | ["increment", "decrement"], 33 | before="_control_before_change", 34 | after="_control_after_change", 35 | ) 36 | 37 | def _control_before_change(self, call, notify): 38 | return self.value 39 | 40 | def _control_after_change(self, answer, notify): 41 | notify(old=answer["before"], new=self.value) 42 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from spectate import mvc 4 | 5 | from .mock import model_events, Counter 6 | 7 | 8 | def test_control_is_only_for_model(): 9 | """Controls must be defined on a Model""" 10 | with raises(RuntimeError): 11 | 12 | class X: 13 | _control = mvc.Control("something") 14 | 15 | 16 | def test_control_using_functions(): 17 | 18 | calls = [] 19 | 20 | def _control_before(value, call, notify): 21 | calls.append("before") 22 | 23 | def _control_after(value, call, notify): 24 | calls.append("after") 25 | 26 | class X(mvc.Model): 27 | def method(self): 28 | calls.append("during") 29 | 30 | control = mvc.Control("method", before=_control_before, after=_control_after) 31 | 32 | X().method() 33 | assert calls == ["before", "during", "after"] 34 | 35 | 36 | def test_control_using_string_reference(): 37 | 38 | calls = [] 39 | 40 | class X(mvc.Model): 41 | def method(self): 42 | pass 43 | 44 | control = mvc.Control("method", before="before", after="after") 45 | 46 | def before(self, call, notify): 47 | calls.append("before") 48 | 49 | def after(self, answer, notify): 50 | calls.append("after") 51 | 52 | X().method() 53 | assert calls == ["before", "after"] 54 | 55 | 56 | def test_control_events_were_sent(): 57 | counter, events = model_events(Counter) 58 | 59 | counter.increment(1) 60 | counter.decrement(2) 61 | counter.increment(3) 62 | counter.decrement(4) 63 | 64 | assert events == [ 65 | {"old": 0, "new": 1}, 66 | {"old": 1, "new": -1}, 67 | {"old": -1, "new": 2}, 68 | {"old": 2, "new": -2}, 69 | ] 70 | 71 | 72 | def test_override_control_methods_in_subclass(): 73 | class MyCounter(Counter): 74 | def _control_before_change(self, call, notify): 75 | notify(message="before") 76 | return super()._control_before_change(call, notify) 77 | 78 | counter, events = model_events(MyCounter) 79 | 80 | counter.increment(1) 81 | 82 | assert events == [{"message": "before"}, {"old": 0, "new": 1}] 83 | 84 | 85 | def test_add_new_control_in_subclass(): 86 | class MyCounter(Counter): 87 | 88 | _added_control = mvc.Control("increment", before="_added_before") 89 | 90 | def _added_before(self, call, notify): 91 | notify(message="before") 92 | 93 | counter, events = model_events(MyCounter) 94 | 95 | counter.increment(1) 96 | 97 | assert events == [{"message": "before"}, {"old": 0, "new": 1}] 98 | 99 | 100 | def test_structure_events(): 101 | class Container(mvc.Structure): 102 | def __init__(self, name): 103 | self.name = name 104 | self.value = None 105 | 106 | def set(self, value): 107 | self.value = value 108 | 109 | _control_changes = mvc.Control( 110 | "set", before="_before_change", after="_after_change" 111 | ) 112 | 113 | def _before_change(self, call, notify): 114 | return self.value 115 | 116 | def _after_change(self, answer, notify): 117 | notify(old=answer["before"], new=self.value) 118 | 119 | def __repr__(self): 120 | return "Container(%r)" % self.name 121 | 122 | s0 = Container("s0") 123 | s1 = Container("s1") 124 | s2 = Container("s2") 125 | 126 | calls = [] 127 | 128 | @mvc.view(s0) 129 | def on_change(value, events): 130 | calls.append(value) 131 | 132 | s0.set(s1) 133 | s1.set(s2) 134 | s2.set(None) 135 | 136 | assert calls == [s0, s1, s2] 137 | calls.clear() 138 | 139 | # remove children 140 | s0.set(None) 141 | 142 | assert calls == [s0] 143 | calls.clear() 144 | 145 | # these should not trigger events because they are no longer 146 | # attached to the root container s0 147 | s1.set(None) 148 | s2.set(None) 149 | 150 | assert not calls 151 | 152 | 153 | def test_notifier_context_manager(): 154 | calls = [] 155 | m = mvc.Model() 156 | 157 | @mvc.view(m) 158 | def viewer(m, events): 159 | calls.extend(events) 160 | 161 | with mvc.notifier(m) as notify: 162 | notify(data=1) 163 | assert calls == [] 164 | 165 | assert calls == [{"data": 1}] 166 | calls.clear() 167 | 168 | with mvc.notifier(m) as notify: 169 | notify(data=1) 170 | notify(data=2) 171 | 172 | assert calls == [{"data": 1}, {"data": 2}] 173 | 174 | 175 | def test_link_and_unlink_inner_models(): 176 | calls = [] 177 | 178 | parent = mvc.Model() 179 | child = mvc.Model() 180 | grandchild = mvc.Model() 181 | 182 | mvc.link(parent, child) 183 | mvc.link(child, grandchild) 184 | 185 | def trigger_events(): 186 | with mvc.notifier(grandchild) as notify: 187 | notify({"data": 1}) 188 | with mvc.notifier(child) as notify: 189 | notify({"data": 2}) 190 | with mvc.notifier(parent) as notify: 191 | notify({"data": 3}) 192 | copy = calls[:] 193 | calls.clear() 194 | return copy 195 | 196 | @mvc.view(parent) 197 | def viewer(value, events): 198 | calls.append({"v": value, "e": list(events)}) 199 | 200 | assert trigger_events() == [ 201 | {"v": grandchild, "e": [{"data": 1}]}, 202 | {"v": child, "e": [{"data": 2}]}, 203 | {"v": parent, "e": [{"data": 3}]}, 204 | ] 205 | 206 | mvc.unlink(child, grandchild) 207 | assert trigger_events() == [ 208 | {"v": child, "e": [{"data": 2}]}, 209 | {"v": parent, "e": [{"data": 3}]}, 210 | ] 211 | 212 | mvc.unlink(parent, child) 213 | assert trigger_events() == [{"v": parent, "e": [{"data": 3}]}] 214 | 215 | 216 | def test_unlink_middleman_stops_view_of_leaf_models(): 217 | calls = [] 218 | 219 | parent = mvc.Model() 220 | child = mvc.Model() 221 | grandchild = mvc.Model() 222 | 223 | mvc.link(parent, child) 224 | mvc.link(child, grandchild) 225 | 226 | def trigger_events(): 227 | with mvc.notifier(grandchild) as notify: 228 | notify({"data": 1}) 229 | with mvc.notifier(child) as notify: 230 | notify({"data": 2}) 231 | with mvc.notifier(parent) as notify: 232 | notify({"data": 3}) 233 | copy = calls[:] 234 | calls.clear() 235 | return copy 236 | 237 | @mvc.view(parent) 238 | def viewer(value, events): 239 | calls.append({"v": value, "e": list(events)}) 240 | 241 | mvc.unlink(parent, child) 242 | 243 | assert trigger_events() == [{"v": parent, "e": [{"data": 3}]}] 244 | -------------------------------------------------------------------------------- /tests/test_dict.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spectate import mvc 4 | from spectate.mvc import Undefined as undef 5 | 6 | from .mock import model_events, events_to_comparable_list 7 | 8 | 9 | _method_call_and_expected_event = [ 10 | { 11 | "value": {"a": None}, 12 | "method": "__setitem__", 13 | "args": ["a", 1], 14 | "kwargs": {}, 15 | "events": [{"old": None, "new": 1, "key": "a"}], 16 | }, 17 | { 18 | "value": {}, 19 | "method": "__setitem__", 20 | "args": ["a", 1], 21 | "kwargs": {}, 22 | "events": [{"old": undef, "new": 1, "key": "a"}], 23 | }, 24 | { 25 | "value": {"a": None}, 26 | "method": "setdefault", 27 | "args": ["a", 1], 28 | "kwargs": {}, 29 | "events": [], 30 | }, 31 | { 32 | "value": {}, 33 | "method": "setdefault", 34 | "args": ["a", 1], 35 | "kwargs": {}, 36 | "events": [{"old": undef, "new": 1, "key": "a"}], 37 | }, 38 | { 39 | "value": {"a": 1}, 40 | "method": "__delitem__", 41 | "args": ["a"], 42 | "kwargs": {}, 43 | "events": [{"old": 1, "new": undef, "key": "a"}], 44 | }, 45 | { 46 | "value": {"a": 1}, 47 | "method": "pop", 48 | "args": ["a"], 49 | "kwargs": {}, 50 | "events": [{"old": 1, "new": undef, "key": "a"}], 51 | }, 52 | { 53 | "value": {}, 54 | "method": "update", 55 | "args": [{"a": 1, "b": 2}], 56 | "kwargs": {}, 57 | "events": [ 58 | {"old": undef, "new": 1, "key": "a"}, 59 | {"old": undef, "new": 2, "key": "b"}, 60 | ], 61 | }, 62 | { 63 | "value": {"a": None, "b": None}, 64 | "method": "update", 65 | "args": [{"a": 1, "b": 2}], 66 | "kwargs": {}, 67 | "events": [ 68 | {"old": None, "new": 1, "key": "a"}, 69 | {"old": None, "new": 2, "key": "b"}, 70 | ], 71 | }, 72 | { 73 | "value": {"a": 1, "b": 2}, 74 | "method": "clear", 75 | "args": [], 76 | "kwargs": {}, 77 | "events": [ 78 | {"old": 1, "new": undef, "key": "a"}, 79 | {"old": 2, "new": undef, "key": "b"}, 80 | ], 81 | }, 82 | {"value": {}, "method": "clear", "args": [], "kwargs": {}, "events": []}, 83 | ] 84 | 85 | 86 | @pytest.mark.parametrize("expectation", _method_call_and_expected_event) 87 | def test_basic_events(expectation): 88 | value, actual_events = model_events(mvc.Dict, expectation["value"]) 89 | method = getattr(value, expectation["method"]) 90 | args = expectation.get("args", []) 91 | kwargs = expectation.get("kwargs", {}) 92 | method(*args, **kwargs) 93 | expected_events = expectation["events"] 94 | assert events_to_comparable_list(actual_events) == events_to_comparable_list( 95 | expected_events 96 | ) 97 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spectate import mvc 4 | 5 | from .mock import model_events, Counter 6 | 7 | 8 | def test_hold_events(): 9 | counter, events = model_events(Counter) 10 | 11 | with mvc.hold(counter) as cache: 12 | counter.increment(1) 13 | assert cache == [{"old": 0, "new": 1}] 14 | 15 | counter.increment(1) 16 | assert cache == [{"old": 0, "new": 1}, {"old": 1, "new": 2}] 17 | 18 | # Pop off one of the events so 19 | # it isn't sent to notifiers. 20 | cache.pop() 21 | 22 | assert events == [{"old": 0, "new": 1}] 23 | 24 | 25 | def test_hold_uses_events_from_reducer(): 26 | counter, events = model_events(Counter) 27 | 28 | def reducer(model, events): 29 | assert events == [{"old": 0, "new": 1}] 30 | yield {"custom": "event-1"} 31 | yield {"custom": "event-2"} 32 | 33 | with mvc.hold(counter, reducer=reducer): 34 | counter.increment(1) 35 | 36 | assert events == [{"custom": "event-1"}, {"custom": "event-2"}] 37 | 38 | 39 | def test_rollback_events(): 40 | counter, events = model_events(Counter) 41 | 42 | with pytest.raises(ValueError): 43 | with mvc.rollback(counter): 44 | counter.increment(1) 45 | raise ValueError() 46 | 47 | assert not events 48 | 49 | 50 | def test_rollback_calls_undo_without_side_effects(): 51 | calls = [] 52 | counter, events = model_events(Counter) 53 | 54 | def undo(model, events, error): 55 | calls.append(1) 56 | assert error is error_from_rollback 57 | assert events == ({"old": 0, "new": 1},) 58 | # this decrement should not notify 59 | model.decrement(1) 60 | 61 | with pytest.raises(ValueError): 62 | with mvc.rollback(counter, undo=undo): 63 | counter.increment(1) 64 | error_from_rollback = ValueError() 65 | raise error_from_rollback 66 | 67 | assert calls 68 | assert counter.value == 0 69 | 70 | 71 | def test_mute_events(): 72 | counter, events = model_events(Counter) 73 | 74 | with mvc.mute(counter): 75 | counter.increment(1) 76 | counter.increment(1) 77 | 78 | assert events == [] 79 | -------------------------------------------------------------------------------- /tests/test_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spectate import mvc 4 | from spectate.mvc import Undefined as undef 5 | 6 | from .mock import model_events, events_to_comparable_list 7 | 8 | 9 | _method_call_and_expected_event = [ 10 | { 11 | "value": [1, 2, 3], 12 | "method": "__setitem__", 13 | "args": [1, 5], 14 | "kwargs": {}, 15 | "events": [{"old": 2, "new": 5, "index": 1}], 16 | }, 17 | { 18 | "value": [1, 2, 3], 19 | "method": "__delitem__", 20 | "args": [0], 21 | "kwargs": {}, 22 | "events": [ 23 | {"old": 1, "new": 2, "index": 0}, 24 | {"old": 2, "new": 3, "index": 1}, 25 | {"old": 3, "new": undef, "index": 2}, 26 | ], 27 | }, 28 | { 29 | "value": [1, 2, 3], 30 | "method": "append", 31 | "args": [4], 32 | "kwargs": {}, 33 | "events": [{"old": undef, "new": 4, "index": 3}], 34 | }, 35 | { 36 | "value": [1, 2, 3], 37 | "method": "pop", 38 | "args": [], 39 | "kwargs": {}, 40 | "events": [{"old": 3, "new": undef, "index": 2}], 41 | }, 42 | { 43 | "value": [1, 2, 3], 44 | "method": "pop", 45 | "args": [0], 46 | "kwargs": {}, 47 | "events": [ 48 | {"old": 1, "new": 2, "index": 0}, 49 | {"old": 2, "new": 3, "index": 1}, 50 | {"old": 3, "new": undef, "index": 2}, 51 | ], 52 | }, 53 | { 54 | "value": [2, 3], 55 | "method": "insert", 56 | "args": [0, 1], 57 | "kwargs": {}, 58 | "events": [ 59 | {"old": 2, "new": 1, "index": 0}, 60 | {"old": 3, "new": 2, "index": 1}, 61 | {"old": undef, "new": 3, "index": 2}, 62 | ], 63 | }, 64 | { 65 | "value": [], 66 | "method": "extend", 67 | "args": [[1, 2, 3]], 68 | "kwargs": {}, 69 | "events": [ 70 | {"old": undef, "new": 1, "index": 0}, 71 | {"old": undef, "new": 2, "index": 1}, 72 | {"old": undef, "new": 3, "index": 2}, 73 | ], 74 | }, 75 | { 76 | "value": [], 77 | "method": "extend", 78 | "args": [(i for i in range(1, 4))], 79 | "kwargs": {}, 80 | "events": [ 81 | {"old": undef, "new": 1, "index": 0}, 82 | {"old": undef, "new": 2, "index": 1}, 83 | {"old": undef, "new": 3, "index": 2}, 84 | ], 85 | }, 86 | { 87 | "value": [1, 2, 3], 88 | "method": "clear", 89 | "args": [], 90 | "kwargs": {}, 91 | "events": [ 92 | {"old": 1, "new": undef, "index": 0}, 93 | {"old": 2, "new": undef, "index": 1}, 94 | {"old": 3, "new": undef, "index": 2}, 95 | ], 96 | }, 97 | {"value": [], "method": "clear", "args": [], "kwargs": {}, "events": []}, 98 | { 99 | "value": [1, 2, 3], 100 | "method": "remove", 101 | "args": [1], 102 | "kwargs": {}, 103 | "events": [ 104 | {"old": 1, "new": 2, "index": 0}, 105 | {"old": 2, "new": 3, "index": 1}, 106 | {"old": 3, "new": undef, "index": 2}, 107 | ], 108 | }, 109 | { 110 | "value": [3, 2, 1], 111 | "method": "sort", 112 | "args": [], 113 | "kwargs": {}, 114 | "events": [{"old": 3, "new": 1, "index": 0}, {"old": 1, "new": 3, "index": 2}], 115 | }, 116 | { 117 | "value": [1, 2, 3], 118 | "method": "reverse", 119 | "args": [], 120 | "kwargs": {}, 121 | "events": [{"old": 1, "new": 3, "index": 0}, {"old": 3, "new": 1, "index": 2}], 122 | }, 123 | ] 124 | 125 | 126 | @pytest.mark.parametrize("expectation", _method_call_and_expected_event) 127 | def test_basic_events(expectation): 128 | value, actual_events = model_events(mvc.List, expectation["value"]) 129 | method = getattr(value, expectation["method"]) 130 | args = expectation.get("args", []) 131 | kwargs = expectation.get("kwargs", {}) 132 | method(*args, **kwargs) 133 | expected_events = expectation["events"] 134 | assert events_to_comparable_list(actual_events) == events_to_comparable_list( 135 | expected_events 136 | ) 137 | -------------------------------------------------------------------------------- /tests/test_object.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spectate import mvc 4 | from spectate.mvc import Undefined as undef 5 | 6 | from .mock import model_events, events_to_comparable_list 7 | 8 | 9 | _method_call_and_expected_event = [ 10 | { 11 | "value": {}, 12 | "method": "__setattr__", 13 | "args": ["a", 1], 14 | "kwargs": {}, 15 | "events": [{"old": undef, "new": 1, "attr": "a"}], 16 | }, 17 | { 18 | "value": {"a": None}, 19 | "method": "__setattr__", 20 | "args": ["a", 1], 21 | "kwargs": {}, 22 | "events": [{"old": None, "new": 1, "attr": "a"}], 23 | }, 24 | { 25 | "value": {"a": 1}, 26 | "method": "__delattr__", 27 | "args": ["a"], 28 | "kwargs": {}, 29 | "events": [{"old": 1, "new": undef, "attr": "a"}], 30 | }, 31 | ] 32 | 33 | 34 | @pytest.mark.parametrize("expectation", _method_call_and_expected_event) 35 | def test_basic_events(expectation): 36 | value, actual_events = model_events(mvc.Object, expectation["value"]) 37 | method = getattr(value, expectation["method"]) 38 | args = expectation.get("args", []) 39 | kwargs = expectation.get("kwargs", {}) 40 | method(*args, **kwargs) 41 | expected_events = expectation["events"] 42 | assert events_to_comparable_list(actual_events) == events_to_comparable_list( 43 | expected_events 44 | ) 45 | -------------------------------------------------------------------------------- /tests/test_set.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spectate import mvc 4 | 5 | from .mock import model_events, events_to_comparable_list 6 | 7 | 8 | _method_call_and_expected_event = [ 9 | { 10 | "value": {1, 2, 3}, 11 | "method": "clear", 12 | "args": [], 13 | "kwargs": {}, 14 | "events": [{"old": {1, 2, 3}, "new": set()}], 15 | }, 16 | { 17 | "value": set(), 18 | "method": "update", 19 | "args": [[1, 2, 3]], 20 | "kwargs": {}, 21 | "events": [{"old": set(), "new": {1, 2, 3}}], 22 | }, 23 | { 24 | "value": set(), 25 | "method": "add", 26 | "args": [1], 27 | "kwargs": {}, 28 | "events": [{"old": set(), "new": {1}}], 29 | }, 30 | { 31 | "value": {1}, 32 | "method": "remove", 33 | "args": [1], 34 | "kwargs": {}, 35 | "events": [{"old": {1}, "new": set()}], 36 | }, 37 | { 38 | "value": {1}, 39 | "method": "pop", 40 | "args": [], 41 | "kwargs": {}, 42 | "events": [{"old": {1}, "new": set()}], 43 | }, 44 | { 45 | "value": {1}, 46 | "method": "discard", 47 | "args": [1], 48 | "kwargs": {}, 49 | "events": [{"old": {1}, "new": set()}], 50 | }, 51 | {"value": {1}, "method": "discard", "args": [2], "kwargs": {}, "events": []}, 52 | { 53 | "value": {1, 2, 3}, 54 | "method": "intersection_update", 55 | "args": [{2, 3, 4}], 56 | "kwargs": {}, 57 | "events": [{"old": {1}, "new": set()}], 58 | }, 59 | { 60 | "value": {1, 2, 3}, 61 | "method": "symmetric_difference_update", 62 | "args": [{2, 3, 4}], 63 | "kwargs": {}, 64 | "events": [{"old": {2, 3}, "new": {4}}], 65 | }, 66 | ] 67 | 68 | 69 | @pytest.mark.parametrize("expectation", _method_call_and_expected_event) 70 | def test_basic_events(expectation): 71 | value, actual_events = model_events(mvc.Set, expectation["value"]) 72 | method = getattr(value, expectation["method"]) 73 | args = expectation.get("args", []) 74 | kwargs = expectation.get("kwargs", {}) 75 | method(*args, **kwargs) 76 | expected_events = expectation["events"] 77 | assert events_to_comparable_list(actual_events) == events_to_comparable_list( 78 | expected_events 79 | ) 80 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = {py36,py37}-nocov, py38-{cov,mypy,lint,docs} 4 | 5 | [travis] 6 | python = 7 | 3.6: py36-nocov 8 | 3.7: py37-nocov 9 | 3.8: py38-{cov,lint,docs} 10 | 11 | [testenv] 12 | wheel = true 13 | extras = all 14 | passenv = * 15 | usedevelop = 16 | nocov: false 17 | cov: true 18 | deps = 19 | nocov: -r requirements/test.txt 20 | cov: -r requirements/test.txt 21 | commands = 22 | nocov: pytest tests --no-cov {posargs} 23 | cov: pytest tests {posargs} 24 | 25 | [testenv:py38-lint] 26 | skip_install = true 27 | deps = -r requirements/lint.txt 28 | commands = 29 | black . --check --exclude "idom/client/static/node_modules/.*" 30 | flake8 idom tests docs 31 | 32 | [testenv:py38-docs] 33 | deps = -r requirements/docs.txt 34 | commands = 35 | sphinx-build -b html docs/source docs/build 36 | sphinx-build -b doctest docs/source docs/build 37 | --------------------------------------------------------------------------------