././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6205437 echo-0.16.0/0000755000175100017510000000000015252526356012175 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.740173 echo-0.16.0/.coveragerc0000644000175100017510000000010015252526320014274 0ustar00runnerrunner[report] omit = echo/tests/*, echo/conftest.py, echo/qt/tests/* ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6092021 echo-0.16.0/.github/0000755000175100017510000000000015252526356013535 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.740173 echo-0.16.0/.github/dependabot.yml0000644000175100017510000000035015252526320016352 0ustar00runnerrunnerversion: 2 updates: - package-ecosystem: "github-actions" directory: ".github/workflows" schedule: interval: "monthly" groups: actions: patterns: - "*" cooldown: default-days: 7 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7408392 echo-0.16.0/.github/release.yml0000644000175100017510000000072015252526320015666 0ustar00runnerrunnerchangelog: exclude: authors: - dependabot[bot] - pre-commit-ci[bot] labels: - no-changelog-entry-needed - skip-changelog categories: - title: Bug Fixes labels: - bug - title: New Features labels: - enhancement - title: Documentation labels: - documentation - title: Performance labels: - performance - title: Other Changes labels: - "*" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6096373 echo-0.16.0/.github/workflows/0000755000175100017510000000000015252526356015572 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7408392 echo-0.16.0/.github/workflows/ci_workflows.yml0000644000175100017510000000572015252526320021020 0ustar00runnerrunnername: CI Workflows on: push: branches: - main tags: - '*' pull_request: permissions: {} jobs: tests: permissions: id-token: write uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@e5af21e4ae37af089a73bb032e10b78b0e746174 # v2 with: coverage: codecov-oidc display: true # Linux PyQt 5.15 and 6.3 installations require apt-getting xcb and EGL deps libraries: | apt: - '^libxcb.*-dev' - libxkbcommon-x11-dev - libegl1 envs: | # Standard tests # Linux builds - test on all supported PyQt5, PyQt6, PySide2 and PySide6 versions and without qtpy - linux: py310-test-pyqt515 - linux: py312-test-pyqt63 - linux: py311-test-pyqt515 - linux: py313-test-pyqt68 - linux: py310-test-pyside515 - linux: py310-test-pyside63 - linux: py312-test-pyside68 - linux: py312-test # Vue/ipywidgets tests (no Qt required) - linux: py310-test-vue - linux: py313-test-vue # Test a few configurations on macOS - macos: py310-test-pyqt515 - macos: py311-test-pyqt515 - macos: py313-test-pyqt67 - macos: py310-test-pyqt63 - macos: py310-test-pyside63 - macos: py311-test-pyside67 - macos: py310-test - macos: py312-test-vue # Test some configurations on Windows - windows: py310-test-pyqt515 - windows: py312-test-pyqt65 - windows: py313-test-pyqt68 - windows: py310-test-pyside515 - windows: py310-test-pyside65 - windows: py311-test - windows: py312-test-vue # Try out documentation build on Linux, macOS and Windows - linux: py310-docs-pyqt514 coverage: false - linux: py312-docs-pyqt67 coverage: false - macos: py310-docs-pyqt515 coverage: false - windows: py310-docs-pyqt515 coverage: false build: needs: tests uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish_pure_python.yml@e5af21e4ae37af089a73bb032e10b78b0e746174 # v2 with: save_artifacts: true upload_to_pypi: false libraries: '^libxcb.*-dev libxkbcommon-x11-dev libgl1 xvfb' test_extras: 'test,qt' test_command: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & sleep 3; DISPLAY=:99.0 pytest --pyargs echo upload: if: startsWith(github.ref, 'refs/tags/v') name: Upload built artifacts to PyPI runs-on: ubuntu-latest needs: [build] environment: pypi permissions: id-token: write steps: - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true pattern: dist-* path: dist - name: Run upload uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/.github/workflows/update-changelog.yaml0000644000175100017510000000206615252526320021660 0ustar00runnerrunner# This workflow takes the GitHub release notes an updates the changelog on the # main branch with the body of the release notes, thereby keeping a log in # the git repo of the changes. name: "Update Changelog" on: release: types: [released] permissions: contents: write jobs: update: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main persist-credentials: false - name: Update Changelog uses: stefanzweifel/changelog-updater-action@a938690fad7edf25368f37e43a1ed1b34303eb36 # v1 with: release-notes: ${{ github.event.release.body }} latest-version: ${{ github.event.release.name }} path-to-changelog: CHANGES.md - name: Commit updated CHANGELOG uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 with: branch: main commit_message: Update CHANGELOG file_pattern: CHANGES.md ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/.gitignore0000644000175100017510000000053015252526320014152 0ustar00runnerrunner*.py[cod] # C extensions *.so # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 __pycache__ # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox nosetests.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject _build htmlcov doc/api .eggs ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/.pre-commit-config.yaml0000644000175100017510000000121315252526320016442 0ustar00runnerrunnerci: autofix_commit_msg: "pre-commit fixes" autofix_prs: false autoupdate_schedule: 'monthly' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: check-merge-conflict - id: check-added-large-files - id: mixed-line-ending - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: v1.30.0 hooks: - id: zizmor - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.6 hooks: - id: ruff args: [--fix] - id: ruff-format ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/.readthedocs.yml0000644000175100017510000000042715252526320015255 0ustar00runnerrunnerversion: 2 build: os: "ubuntu-22.04" tools: python: "3.12" sphinx: builder: html configuration: doc/conf.py fail_on_warning: true python: install: - method: pip path: . extra_requirements: - docs - qt - vue formats: [] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/CHANGES.md0000644000175100017510000001402215252526320013555 0ustar00runnerrunner# Full changelog ## v0.13.1 - 2026-03-26 ### What's Changed #### Other Changes * Minimize comm updates between Python and vue, and add option to log comm calls by @astrofrog in https://github.com/glue-viz/echo/pull/59 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.13.0...v0.13.1 ## v0.13.0 - 2026-03-25 ### What's Changed #### New Features * Add vue/ipywidgets autoconnect module by @astrofrog in https://github.com/glue-viz/echo/pull/56 * Added skip parameter for vue autoconnect by @astrofrog in https://github.com/glue-viz/echo/pull/58 #### Other Changes * Updated RTD config to fix build error by @astrofrog in https://github.com/glue-viz/echo/pull/57 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.12.1...v0.13.0 ## v0.12.1 - 2026-02-11 ### What's Changed #### Bug Fixes * Fixed bug with connect_combo_selection and connect_list_selection with aliases by @astrofrog in https://github.com/glue-viz/echo/pull/55 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.12.0...v0.12.1 ## v0.12.0 - 2026-02-10 ### What's Changed #### New Features * Add ability to define callback property aliases by @astrofrog in https://github.com/glue-viz/echo/pull/53 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.11.1...v0.12.0 ## v0.11.1 - 2025-11-22 ### What's Changed #### Bug Fixes * Respect priority between one- and two-argument callbacks by @Carifio24 in https://github.com/glue-viz/echo/pull/52 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.11.0...v0.11.1 ## v0.11.0 - 2025-06-12 ### What's Changed #### Bug Fixes * fix: prevent dangling references in dict and listCallbackProperties by @iisakkirotko in https://github.com/glue-viz/echo/pull/43 * Fixed bug that occurred if a callback attribute used a property which didn't exist yet by @astrofrog in https://github.com/glue-viz/echo/pull/50 #### Performance * Fixed a bug that caused unnecessary global callbacks even if value did not change by @astrofrog in https://github.com/glue-viz/echo/pull/46 * Avoid storing duplicate functions/methods in CallbackContainer by @astrofrog in https://github.com/glue-viz/echo/pull/48 #### Other Changes * Updated package infrastructure to use pyproject.toml by @astrofrog in https://github.com/glue-viz/echo/pull/47 * Fix license in pyproject.toml by @astrofrog in https://github.com/glue-viz/echo/pull/49 ### New Contributors * @iisakkirotko made their first contribution in https://github.com/glue-viz/echo/pull/43 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.10.0...v0.11.0 ## v0.10.0 - 2025-05-09 ### What's Changed #### New Features * Implement a new type of callback called validators, which get called before a value changes by @astrofrog in https://github.com/glue-viz/echo/pull/42 #### Other Changes * Add Python 3.12 envs and newer PyQt6 + PySide6 configurations to CI by @dhomeier in https://github.com/glue-viz/echo/pull/41 * Fix libegl1 apt installation in Ubuntu CI; add py313 and PyQt6/PySide6 6.8 by @dhomeier in https://github.com/glue-viz/echo/pull/44 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.9.0...v0.10.0 ## v0.9.0 - 2024-07-19 ### What's Changed #### Bug Fixes * Remove deprecated call to pkg_resources by @jfoster17 in https://github.com/glue-viz/echo/pull/36 #### New Features * Add Qt connection handler for date/time widgets by @Carifio24 in https://github.com/glue-viz/echo/pull/37 #### Other Changes * Make qtpy an optional dependency by @Carifio24 in https://github.com/glue-viz/echo/pull/38 ### New Contributors * @jfoster17 made their first contribution in https://github.com/glue-viz/echo/pull/36 * @Carifio24 made their first contribution in https://github.com/glue-viz/echo/pull/37 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.8.0...v0.9.0 ## v0.8.0 - 2022-10-13 ### What's Changed #### Bug Fixes - Added test for connect_list_selection and fix remaining PyQt6 issue by @astrofrog in https://github.com/glue-viz/echo/pull/35 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.7...v0.8.0 ## v0.7 - 2022-09-27 ### What's Changed #### Bug Fixes - Fix compatibility with PyQt6 by @astrofrog in https://github.com/glue-viz/echo/pull/29 #### Other Changes - Migrate CI tests and publishing task to GitHub Actions by @dhomeier in https://github.com/glue-viz/echo/pull/33 - Add PySide6 test envs to CI by @dhomeier in https://github.com/glue-viz/echo/pull/34 ### New Contributors - @dhomeier made their first contribution in https://github.com/glue-viz/echo/pull/33 **Full Changelog**: https://github.com/glue-viz/echo/compare/v0.5...v0.6 ## 0.6 (2021-12-14) - Fixed compatibility with recent PyQt5 versions. ## 0.5 (2020-11-08) - Add the ability to specify for `SelectionCallbackProperty` whether to - compare choices using equality or identity. [#26] - - Fixed an issue that could lead to `CallbackContainer` returning dead - weak references during iteration. [#27] - ## 0.4 (2020-05-04) - Added the ability to add arbitrary callbacks to `CallbackDict` and - `CallbackList` via the `.callbacks` attribute. [#25] ## 0.3 (2020-05-04) - Fix setting of defaults in callback list and dict properties. [#24] ## 0.2 (2020-04-11) - Python 3.6 or later is now required. [#20] - - Significant refactoring of the package. [#20] - ## 0.1 (2014-03-13) - Initial version ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7409692 echo-0.16.0/LICENSE0000644000175100017510000000206315252526320013172 0ustar00runnerrunnerThe MIT License (MIT) Copyright (c) 2014 glue-viz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.741173 echo-0.16.0/MANIFEST.in0000644000175100017510000000006215252526320013720 0ustar00runnerrunnerinclude LICENSE include README.md include echo.py ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6205094 echo-0.16.0/PKG-INFO0000644000175100017510000000520715252526356013276 0ustar00runnerrunnerMetadata-Version: 2.4 Name: echo Version: 0.16.0 Summary: Callback Properties in Python Author-email: Chris Beaumont and Thomas Robitaille Maintainer-email: Chris Beaumont and Thomas Robitaille License-Expression: MIT Project-URL: homepage, https://github.com/glue-viz/echo Classifier: Intended Audience :: Developers Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: numpy Provides-Extra: docs Requires-Dist: numpydoc; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx-automodapi; extra == "docs" Requires-Dist: sphinx-rtd-theme; extra == "docs" Provides-Extra: qt Requires-Dist: pyqt5>=5.14; extra == "qt" Requires-Dist: qtpy; extra == "qt" Provides-Extra: vue Requires-Dist: traitlets; extra == "vue" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-cov; extra == "test" Requires-Dist: ipywidgets; extra == "test" Dynamic: license-file |CI Status| |Coverage Status| |PyPI Version| echo: Callback Properties in Python =================================== Echo is a small library for attaching callback functions to property state changes. For example: :: class Switch(object): state = CallbackProperty('off') def report_change(state): print 'the switch is %s' % state s = Switch() add_callback(s, 'state', report_change) s.state = 'on' # prints 'the switch is on' CalllbackProperties can also be built using decorators :: class Switch(object): @callback_property def state(self): return self._state @state.setter def state(self, value): if value not in ['on', 'off']: raise ValueError("invalid setting") self._state = value Full documentation is avilable `here `__ .. |CI Status| image:: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml/badge.svg :target: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml .. |Coverage Status| image:: https://codecov.io/gh/glue-viz/echo/branch/main/graph/badge.svg :target: https://codecov.io/gh/glue-viz/echo .. |PyPI Version| image:: https://img.shields.io/pypi/v/echo.svg :target: https://pypi.org/project/echo/ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.741173 echo-0.16.0/README.rst0000644000175100017510000000247415252526320013662 0ustar00runnerrunner|CI Status| |Coverage Status| |PyPI Version| echo: Callback Properties in Python =================================== Echo is a small library for attaching callback functions to property state changes. For example: :: class Switch(object): state = CallbackProperty('off') def report_change(state): print 'the switch is %s' % state s = Switch() add_callback(s, 'state', report_change) s.state = 'on' # prints 'the switch is on' CalllbackProperties can also be built using decorators :: class Switch(object): @callback_property def state(self): return self._state @state.setter def state(self, value): if value not in ['on', 'off']: raise ValueError("invalid setting") self._state = value Full documentation is avilable `here `__ .. |CI Status| image:: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml/badge.svg :target: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml .. |Coverage Status| image:: https://codecov.io/gh/glue-viz/echo/branch/main/graph/badge.svg :target: https://codecov.io/gh/glue-viz/echo .. |PyPI Version| image:: https://img.shields.io/pypi/v/echo.svg :target: https://pypi.org/project/echo/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6111505 echo-0.16.0/doc/0000755000175100017510000000000015252526356012742 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.741173 echo-0.16.0/doc/Makefile0000644000175100017510000001514515252526320014377 0ustar00runnerrunner# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = -W SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Echo.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Echo.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Echo" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Echo" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/api.rst0000644000175100017510000000065215252526320014237 0ustar00runnerrunner.. _api: Detailed API documentation -------------------------- Core functionality ^^^^^^^^^^^^^^^^^^ .. automodapi:: echo :no-heading: :no-inheritance-diagram: :inherited-members: .. _qtapi: Qt helpers ^^^^^^^^^^ .. automodapi:: echo.qt :no-heading: :no-inheritance-diagram: .. _vueapi: Vue/ipywidgets helpers ^^^^^^^^^^^^^^^^^^^^^^ .. automodapi:: echo.vue :no-heading: :no-inheritance-diagram: ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/conf.py0000644000175100017510000000242715252526320014235 0ustar00runnerrunnerfrom echo import __version__ project = "echo" copyright = "2014-2020, Chris Beaumont and Thomas Robitaille" version = release = __version__ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.viewcode", "sphinx.ext.intersphinx", "sphinx_automodapi.automodapi", "sphinx_automodapi.smart_resolver", "numpydoc", ] templates_path = ["_templates"] source_suffix = ".rst" master_doc = "index" exclude_patterns = ["_build"] pygments_style = "sphinx" nitpicky = True nitpick_ignore = [ ("py:class", "echo.containers.ContainerMixin"), ("py:class", "None. Remove all items from D."), ("py:class", "a shallow copy of D"), ("py:class", "a set-like object providing a view on D's items"), ("py:class", "a set-like object providing a view on D's keys"), ("py:class", "v, remove specified key and return the corresponding value."), ("py:class", "None. Update D from dict/iterable E and F."), ("py:class", "None. Update D from mapping/iterable E and F."), ("py:class", "an object providing a view on D's values"), ] numpydoc_class_members_toctree = False intersphinx_cache_limit = 10 intersphinx_mapping = { "sphinx": ("https://www.sphinx-doc.org/en/master/", None), "python": ("https://docs.python.org/3.6", None), } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/core.rst0000644000175100017510000000671715252526320014426 0ustar00runnerrunner.. currentmodule:: echo Quick Start ----------- There are two syntaxes for creating callback properties. The first option is to create a CallbackProperty instance at the class level:: class Foo(object): bar = CallbackProperty(5) # initial value is 5 baz = CallbackProperty() # intial value is None These behave like normal instance attributes. Altnernatively, you can use the decorator syntax (all lowercase and with an underscore) to implement custom getting and setting logic:: class Foo(object): @callback_property def bar(self): ... getter function .... @bar.setter def bar(self, value): ... setter function ... To attach a function to a callback property, use the :func:`add_callback` function:: def callback(new_value): print('new value is %g' % new_value) f = Foo() add_callback(f, 'bar', callback) # The following will print 'new value is 10' f.bar = 10 .. note:: When calling :func:`add_callback`, the callback property is referred to by name, as a string. You can also write callbacks that receive both the old and new value of the property, by setting ``echo_old=True`` in :func:`add_callback`:: def callback(old, new): print('changed from %s to %s' % (old, new)) f = Foo() add_callback(f, 'bar', callback, echo_old=True) # The following will print 'changed from 5 to 10' f.bar = 10 Delaying, ignoring, and removing callbacks ------------------------------------------ Callback functions can be removed with :func:`remove_callback`:: remove_callback(f, 'bar', callback) The :func:`ignore_callback` and :func:`delay_callback` context managers temporarily disable callbacks from being called:: with delay_callback(f, 'bar'): f.bar = 10 f.bar = 20 f.bar = 30 Inside the context manager, none of the callbacks for ``f.bar`` will be called -- this is useful in situations where you might be making temporary, incremental changes to a callback property. The difference between :func:`delay_callback` and :func:`ignore_callback` is whether any callbacks will be invoked at the end of the context block. Callbacks are never triggered inside :func:`ignore_callback`, where as they are triggered a single time inside :func:`delay_callback` if the final state has changed. Property aliases ---------------- When renaming a callback property, you can use :class:`CallbackPropertyAlias` to maintain backward compatibility by creating an alias that redirects to the new property name:: class Foo(object): line_color = CallbackProperty('red') # new name linecolor = CallbackPropertyAlias('line_color') # old name The alias is transparent -- getting or setting via the alias accesses the target property. Callbacks attached via either name are registered on the target property:: f = Foo() add_callback(f, 'linecolor', callback) # attaches to line_color f.line_color = 'blue' # triggers callback By default, aliases are silent. To emit deprecation warnings when the alias is used, set ``deprecated=True`` or provide a custom warning message:: linecolor = CallbackPropertyAlias('line_color', deprecated=True) lc = CallbackPropertyAlias('line_color', warning='Use line_color instead') For :class:`~echo.selection.SelectionCallbackProperty` and other subclasses, the alias proxies attribute access to the target, so methods like ``get_choices`` and ``set_choices`` work through the alias. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/gui.rst0000644000175100017510000000463415252526320014256 0ustar00runnerrunner.. currentmodule:: echo.qt Interfacing with Qt widgets --------------------------- When designing GUIs, it can be useful to have class properties link directly to widgets. Echo currently includes functions to connect to Qt widgets, which are described below. Contributions for other GUI frameworks are welcome! We recommend using Qt libraries via the `QtPy `_ package, which provides a uniform API to PyQt4, PyQt5, and PySide, and we use this in the following examples. Let's assume we have a simple Qt window with a single checkbox:: from qtpy import QtWidgets class MySimpleWindow(QtWidgets.QMainWindow): def __init__(self, parent=None): super(MySimpleWindow, self).__init__(parent=parent) self.checkbox = QtWidgets.QCheckBox() We can now add a callback property to the class and connect the property to the Qt checkbox:: from qtpy import QtWidgets from echo import CallbackProperty from echo.qt import connect_checkable_button class MySimpleWindow(QtWidgets.QMainWindow): active = CallbackProperty(True) def __init__(self, parent=None): super(MySimpleWindow, self).__init__(parent=parent) self.checkbox = QtWidgets.QCheckBox() self.setCentralWidget(self.checkbox) self._connection = connect_checkable_button(self, 'active', self.checkbox) Note that the connection object needs to be stored in a variable, as one there are no references left to the connection object, the connection is removed. Let's now write a small script that uses this Qt window and adds a callback when ``active`` is changed:: from echo import add_callback def toggled(new): print("Checkbox toggled: %s" % new) app = QtWidgets.QApplication(['']) window = MySimpleWindow() window.show() add_callback(window, 'active', toggled) app.exec_() If you run this script, and click on the checkbox multiple times, the output in the terminal will be:: Checkbox toggled: False Checkbox toggled: True Checkbox toggled: False In addition, if you need to access the value of the checkbox in the code, you can now simply access ``window.active`` rather than ``window.checkbox.isChecked``. A number of other functions are available to connect to other types of widgets, including combo boxes and text fields - see the API documentation for the :ref:`qtapi` for more details. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/index.rst0000644000175100017510000000200115252526320014563 0ustar00runnerrunnerEcho: Callback Properties in Python =================================== Overview -------- Echo is a small Python module to create properties that you can attach callback functions to:: >>> from echo import CallbackProperty, add_callback >>> class Switch(object): ... state = CallbackProperty('off') >>> def alert(value): ... print("The switch is %s" % value) >>> s = Switch() >>> add_callback(s, 'state', alert) >>> s.state 'off' >>> s.state = 'on' The switch is on >>> s.state 'on' >>> s.state = 'off' The switch is off The main features of Echo are: * A simple, property-like interface to monitor state changes * Decorator syntax to create callback property getter/setter methods, similar to ``@property`` * Context managers to delay or ignore callback events * Helper functions to connect callback properties to GUI elements (Qt and ipywidgets/ipyvuetify are supported) User guide ---------- .. toctree:: :maxdepth: 1 installation core gui vue api ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/installation.rst0000644000175100017510000000013315252526320016161 0ustar00runnerrunner Installation ------------ You can install echo from PyPI via pip:: pip install echo ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/doc/vue.rst0000644000175100017510000001667115252526320014275 0ustar00runnerrunner.. currentmodule:: echo.vue Interfacing with ipyvuetify widgets ------------------------------------ Echo can automatically connect callback properties to `ipyvuetify `_ widgets. An ipyvuetify widget is a Python class backed by a Vue template file that describes the UI layout using Vuetify components. For example, a simple widget might look like this: .. code-block:: python import ipyvuetify as v import traitlets class MyWidget(v.VuetifyTemplate): template_file = (__file__, 'my_widget.vue') with a corresponding Vue template ``my_widget.vue``: .. code-block:: none Normally, you would need to manually define traitlets on the widget class for each ``v-model`` binding in the template, and then write code to keep those traitlets in sync with your application state. Echo automates this entirely with :func:`autoconnect_callbacks_to_vue`: .. code-block:: python from echo import CallbackProperty, SelectionCallbackProperty from echo.vue import autoconnect_callbacks_to_vue class MyState: active = CallbackProperty(False) x_min = CallbackProperty(0.0) x_att = SelectionCallbackProperty() state = MyState() widget = MyWidget() connections = autoconnect_callbacks_to_vue(state, widget) This parses the Vue template, creates the necessary traitlets on the widget automatically, and sets up bidirectional connections between the callback properties and the widget traitlets. Changes to the callback properties are reflected in the widget, and user interactions in the widget are propagated back to the callback properties. The connection type is inferred from the Vue component tag: * ``v-switch``, ``v-checkbox`` -- boolean property * ``v-text-field`` -- string property (or integer when ``type="number"`` is set) * ``v-slider``, ``v-range-slider`` -- float property * ``v-select``, ``v-combobox``, ``v-autocomplete`` -- selection property For custom components not in the default mapping, use the ``echo-type`` attribute to specify the connection type. With ipyvue/ipyvuetify 1.x (Vue/Vuetify 2), use ``:value.sync``:: With ipyvue/ipyvuetify 3.x (Vue/Vuetify 3), use the argument-based ``v-model:value`` syntax:: The supported ``echo-type`` values are: ``bool``, ``int``, ``float``, ``text``, ``selection``, ``list``, ``dict``, and ``any``. Selection items contain both a ``text`` key for ipyvuetify 1.x (Vuetify 2) and a ``title`` key for ipyvuetify 3.x (Vuetify 3). Only properties referenced in the template that match callback properties on the state object are connected. A warning is issued if the template references a name that does not correspond to a callback property. Connecting list and dict properties ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``ListCallbackProperty`` and ``DictCallbackProperty`` can be connected to Vue using the ``list`` and ``dict`` types. These are typically specified via the ``extras`` parameter since list/dict bindings are not automatically inferred from standard Vuetify tags: .. code-block:: python from echo import ListCallbackProperty, DictCallbackProperty class AppState: items = ListCallbackProperty([{'name': 'a'}]) settings = DictCallbackProperty({'visible': True}) state = AppState() widget = MyWidget() autoconnect_callbacks_to_vue(state, widget, extras={'items': 'list', 'settings': 'dict'}) Mutations to the containers (e.g. ``state.items.append(...)`` or ``state.settings['visible'] = False``) are synced to the widget. Each mutation triggers a full sync of the container, so use ``delay_callback`` to batch several mutations into a single sync. In the reverse direction (widget → state), the existing ``CallbackList`` / ``CallbackDict`` objects are updated in place so that any attached callbacks are preserved. Discovering properties from Python ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ By default, ``autoconnect_callbacks_to_vue`` discovers which properties to connect by parsing the Vue template. For state objects whose properties may not all appear in the template (e.g. an application-level state used across many components), you can instead discover properties directly from the Python class: .. code-block:: python autoconnect_callbacks_to_vue(state, widget, infer_properties_from='python') This discovers all ``CallbackProperty`` attributes on the state object and infers the connection type from the property descriptor: * ``ListCallbackProperty`` → ``list`` * ``DictCallbackProperty`` → ``dict`` * ``SelectionCallbackProperty`` → ``selection`` * All other ``CallbackProperty`` → ``any`` (uses ``traitlets.Any``, no type coercion) You can still use ``extras`` to override specific properties with custom transforms, and ``skip`` to exclude properties. Connecting a subset of properties ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``only`` parameter accepts a set of property names (types are auto-inferred) or a dict (with explicit type strings or transform tuples). When ``only`` is provided, no template parsing or property discovery takes place — only the listed properties are connected: .. code-block:: python # Auto-infer types from descriptors: autoconnect_callbacks_to_vue(viewer_state, widget, only={'x_min', 'x_max', 'x_log'}) # Explicit types / transforms: autoconnect_callbacks_to_vue(viewer_state, widget, only={'x_min': 'float', 'cmap': ('text', cmap_to_name, name_to_cmap)}) Debugging with comm logging ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Call :func:`enable_comm_logging` at the start of a notebook to log every comm message exchanged between Python and the browser frontend for all widgets connected via :func:`autoconnect_callbacks_to_vue` after that point. Messages are emitted at ``DEBUG`` level on the ``echo`` logger. .. code-block:: python from echo.vue import enable_comm_logging, disable_comm_logging enable_comm_logging() # all future widgets will be logged disable_comm_logging() # stop and unpatch any instrumented widgets To see the messages, configure the ``echo`` logger. To log to the terminal: .. code-block:: python import logging logging.getLogger('echo').setLevel(logging.DEBUG) logging.getLogger('echo').addHandler(logging.StreamHandler()) To log to a file (useful in glue-jupyter where stdout may be swallowed): .. code-block:: python import logging handler = logging.FileHandler('/tmp/echo.log') handler.setFormatter(logging.Formatter('%(message)s')) logging.getLogger('echo').setLevel(logging.DEBUG) logging.getLogger('echo').addHandler(handler) Each log line is prefixed with the direction of the message: * ``[PY->VUE]`` -- state sent from Python to the browser * ``[VUE->PY]`` -- state received from the browser into Python This is useful for diagnosing circular update loops, where a value is set in Python, propagated to Vue, modified (e.g. by rounding), sent back, and triggers another update. Such loops show up as a rapid sequence of alternating ``[PY->VUE]`` / ``[VUE->PY]`` lines with the same keys. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570285.612734 echo-0.16.0/echo/0000755000175100017510000000000015252526356013113 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7414079 echo-0.16.0/echo/__init__.py0000644000175100017510000000023315252526320015211 0ustar00runnerrunnerfrom .core import * # noqa from .alias import * # noqa from .containers import * # noqa from .selection import * # noqa from .version import * # noqa ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7417798 echo-0.16.0/echo/alias.py0000644000175100017510000000526315252526320014553 0ustar00runnerrunner__all__ = ["CallbackPropertyAlias"] class CallbackPropertyAlias: """ An alias for a CallbackProperty that redirects access to a target property. This is useful for renaming callback properties while maintaining backwards compatibility. Optionally emits deprecation warnings when the alias is used. Parameters ---------- target : str The name of the target property to redirect to deprecated : bool, optional If `True`, emit a deprecation warning when the alias is accessed. Defaults to `False` (silent alias). warning : str, optional A custom warning message. If not provided, a default message will be generated. Setting this implies ``deprecated=True``. Examples -------- :: class Foo(HasCallbackProperties): # New property name line_color = CallbackProperty('red') # Silent alias for backwards compatibility linecolor = CallbackPropertyAlias('line_color') # Deprecated alias with warning lc = CallbackPropertyAlias('line_color', deprecated=True) """ def __init__(self, target, deprecated=False, warning=None): self._target = target # Setting a custom warning message implies deprecated=True self._deprecated = deprecated or (warning is not None) self._warning = warning self._owner = None self._name = None def __set_name__(self, owner, name): self._owner = owner self._name = name def _warn(self): if self._deprecated: import warnings message = self._warning or f"'{self._name}' is deprecated, use '{self._target}' instead" warnings.warn(message, DeprecationWarning, stacklevel=3) def __get__(self, instance, owner=None): if instance is None: return self self._warn() return getattr(instance, self._target) def __set__(self, instance, value): self._warn() setattr(instance, self._target, value) def __getattr__(self, attr): # Proxy attribute access to the target property (e.g., for # SelectionCallbackProperty.get_choices, set_choices, etc.) if attr.startswith("_"): raise AttributeError(attr) if self._owner is None: raise AttributeError(f"Cannot access '{attr}' before class is fully defined") target_prop = getattr(self._owner, self._target) return getattr(target_prop, attr) @property def _target_property(self): """Return the target CallbackProperty object.""" if self._owner is None: return None return getattr(self._owner, self._target) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7417798 echo-0.16.0/echo/callback_container.py0000644000175100017510000001152415252526320017255 0ustar00runnerrunnerimport weakref from functools import partial __all__ = ["CallbackContainer"] class CallbackContainer: """ A list-like container for callback functions. We need to be careful with storing references to methods, because if a callback method is on a class which contains both the callback and a callback property, a circular reference is created which results in a memory leak. Instead, we need to use a weak reference which results in the callback being removed if the instance is destroyed. This container class takes care of this automatically. """ def __init__(self): self.callbacks = [] def clear(self): self.callbacks.clear() def _wrap(self, value, priority=0): """ Given a function/method, this will automatically wrap a method using weakref to avoid circular references. """ if not callable(value): raise TypeError("Only callable values can be stored in CallbackContainer") elif self.is_bound_method(value): # We are dealing with a bound method. Method references aren't # persistent, so instead we store a reference to the function # and instance. value = (weakref.ref(value.__func__), weakref.ref(value.__self__, self._auto_remove), priority) else: value = (value, priority) return value def _auto_remove(self, method_instance): # Called when weakref detects that the instance on which a method was # defined has been garbage collected. for value in self.callbacks[:]: if isinstance(value, tuple) and value[1] is method_instance: self.callbacks.remove(value) def __contains__(self, value): return self._contains(value) def _contains(self, value, priority=None): # Check if an entry matches the value and the priority, if not None if self.is_bound_method(value): for callback in self.callbacks[:]: if len(callback) == 3 and value.__func__ is callback[0]() and value.__self__ is callback[1](): if priority is None or priority == callback[2]: return True else: return False else: for callback in self.callbacks[:]: if len(callback) == 2 and value is callback[0]: if priority is None or priority == callback[1]: return True else: return False def iterator(self, sort=True, priority=False): """ Returns an iterator over the callback functions inside the container, skipping and removing callbacks where the underlying instance has already been garbage collected. Parameters --------- sort: bool, optional Whether the yielded callbacks will be sorted (by priority). Defaults to `False`. priority: bool, optional If `True`, each iteration will yield a tuple (callback, priority). If `False` (the default), only the callback is yielded. """ if sort: iterator = sorted(self.callbacks, key=lambda x: x[-1], reverse=True) else: iterator = self.callbacks for callback in iterator: if len(callback) == 3: func = callback[0]() inst = callback[1]() # In some cases it can happen that the instance has been # garbage collected but _auto_remove hasn't been called, so we # just check here that the weakrefs were resolved if func is None or inst is None: continue result = partial(func, inst) else: result = callback[0] if priority: yield result, callback[-1] else: yield result def __iter__(self): yield from self.iterator() def __len__(self): return len(self.callbacks) @staticmethod def is_bound_method(func): return hasattr(func, "__func__") and getattr(func, "__self__", None) is not None def append(self, value, priority=0): # If we already have the same callback with the same priority, we can ignore if not self._contains(value, priority=priority): self.callbacks.append(self._wrap(value, priority=priority)) def remove(self, value): if self.is_bound_method(value): for callback in self.callbacks[:]: if len(callback) == 3 and value.__func__ is callback[0]() and value.__self__ is callback[1](): self.callbacks.remove(callback) else: for callback in self.callbacks[:]: if len(callback) == 2 and value is callback[0]: self.callbacks.remove(callback) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7417798 echo-0.16.0/echo/conftest.py0000644000175100017510000000105015252526320015275 0ustar00runnerrunnertry: import qtpy # noqa except Exception: QT_INSTALLED = False else: QT_INSTALLED = True qapp = None def get_qapp(): global qapp from qtpy import QtWidgets qapp = QtWidgets.QApplication.instance() if qapp is None: qapp = QtWidgets.QApplication([""]) return qapp def pytest_configure(config): if QT_INSTALLED: from qtpy import QtWidgets # noqa app = get_qapp() # noqa def pytest_unconfigure(config): if QT_INSTALLED: global qapp qapp.exit() qapp = None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7417798 echo-0.16.0/echo/containers.py0000644000175100017510000001757215252526320015635 0ustar00runnerrunnerimport weakref from . import CallbackProperty, HasCallbackProperties from .callback_container import CallbackContainer __all__ = ["CallbackList", "CallbackDict", "ListCallbackProperty", "DictCallbackProperty"] class ContainerMixin: def _setup_container(self): self._callbacks = CallbackContainer() self._item_validators = CallbackContainer() def _prepare_add(self, value): for validator in self._item_validators: value = validator(value) if isinstance(value, list): value = CallbackList(self.notify_all, value) elif isinstance(value, dict): value = CallbackDict(self.notify_all, value) if isinstance(value, HasCallbackProperties): value.add_global_callback(self.notify_all) elif isinstance(value, CallbackList | CallbackDict): value.callback = self.notify_all return value def _cleanup_remove(self, value): if isinstance(value, HasCallbackProperties): value.remove_global_callback(self.notify_all) elif isinstance(value, CallbackList | CallbackDict): value.remove_callback(self.notify_all) def add_callback(self, func, priority=0, validator=False): """ Add a callback to the container. Note that validators are applied on a per item basis, whereas regular callbacks are called with the whole list after modification. Parameters ---------- func : func The callback function to add priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). validator : bool, optional Whether the callback is a validator, which is a special kind of callback that gets called with the item being added to the container *before* the container is modified. The validator can return the value as-is, modify it, or emit warnings or an exception. """ if validator: self._item_validators.append(func, priority=priority) else: self._callbacks.append(func, priority=priority) def remove_callback(self, func): """ Remove a callback from the container. """ for cb in (self._callbacks, self._item_validators): if func in cb: cb.remove(func) def notify_all(self, *args, **kwargs): for callback in self._callbacks: callback(*args, **kwargs) class CallbackList(list, ContainerMixin): """ A list that calls a callback function when it is modified. The first argument should be the callback function (which takes no arguments), and subsequent arguments are as for `list`. """ def __init__(self, callback, *args, **kwargs): super().__init__(*args, **kwargs) self._setup_container() self.add_callback(callback) for index, value in enumerate(self): super().__setitem__(index, self._prepare_add(value)) def __repr__(self): return f"" def append(self, value): super().append(self._prepare_add(value)) self.notify_all() def extend(self, iterable): iterable = [self._prepare_add(value) for value in iterable] super().extend(iterable) self.notify_all() def insert(self, index, value): super().insert(index, self._prepare_add(value)) self.notify_all() def pop(self, index=-1): result = super().pop(index) self._cleanup_remove(result) self.notify_all() return result def remove(self, value): super().remove(value) self._cleanup_remove(value) self.notify_all() def reverse(self): super().reverse() self.notify_all() def sort(self, key=None, reverse=False): super().sort(key=key, reverse=reverse) self.notify_all() def __setitem__(self, slc, new_value): old_values = self[slc] if not isinstance(slc, slice): old_values = [old_values] for old_value in old_values: self._cleanup_remove(old_value) if isinstance(slc, slice): new_value = [self._prepare_add(value) for value in new_value] else: new_value = self._prepare_add(new_value) super().__setitem__(slc, new_value) self.notify_all() def clear(self): for item in self: self._cleanup_remove(item) super().clear() self.notify_all() class CallbackDict(dict, ContainerMixin): """ A dictionary that calls a callback function when it is modified. The first argument should be the callback function (which takes no arguments), and subsequent arguments are passed to `dict`. """ def __init__(self, callback, *args, **kwargs): super().__init__(*args, **kwargs) self._setup_container() self.add_callback(callback) for key, value in self.items(): super().__setitem__(key, self._prepare_add(value)) def clear(self): for value in self.values(): self._cleanup_remove(value) super().clear() self.notify_all() def popitem(self): result = super().popitem() self._cleanup_remove(result) self.notify_all() return result def update(self, *args, **kwargs): values = {} values.update(*args, **kwargs) for key, value in values.items(): values[key] = self._prepare_add(value) super().update(values) self.notify_all() def pop(self, *args, **kwargs): result = super().pop(*args, **kwargs) self._cleanup_remove(result) self.notify_all() return result def __setitem__(self, key, value): if key in self: self._cleanup_remove(self[key]) super().__setitem__(key, self._prepare_add(value)) self.notify_all() def __repr__(self): return f"" class dynamic_callback: function = None def __call__(self, *args, **kwargs): self.function(*args, **kwargs) class ListCallbackProperty(CallbackProperty): """ A list property that calls callbacks when its contents are modified """ def _default_getter(self, instance, owner=None): if instance not in self._values: self._default_setter(instance, self._default or []) return super()._default_getter(instance, owner) def _default_setter(self, instance, value): if not isinstance(value, list): raise TypeError("callback property should be a list") dcb = dynamic_callback() wrapped_list = CallbackList(dcb, value) _instance = weakref.ref(instance) def callback(*args, **kwargs): if _instance() is not None: self.notify(_instance(), wrapped_list, wrapped_list) dcb.function = callback super()._default_setter(instance, wrapped_list) class DictCallbackProperty(CallbackProperty): """ A dictionary property that calls callbacks when its contents are modified """ def _default_getter(self, instance, owner=None): if instance not in self._values: self._default_setter(instance, self._default or {}) return super()._default_getter(instance, owner) def _default_setter(self, instance, value): if not isinstance(value, dict): raise TypeError("Callback property should be a dictionary.") dcb = dynamic_callback() wrapped_dict = CallbackDict(dcb, value) _instance = weakref.ref(instance) def callback(*args, **kwargs): if _instance() is not None: self.notify(_instance(), wrapped_dict, wrapped_dict) dcb.function = callback super()._default_setter(instance, wrapped_dict) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7417798 echo-0.16.0/echo/core.py0000644000175100017510000006451715252526320014421 0ustar00runnerrunnerimport weakref from contextlib import ExitStack, contextmanager from itertools import chain from weakref import WeakKeyDictionary from .alias import CallbackPropertyAlias from .callback_container import CallbackContainer __all__ = [ "CallbackProperty", "callback_property", "add_callback", "remove_callback", "delay_callback", "ignore_callback", "HasCallbackProperties", "keep_in_sync", ] class CallbackProperty: """ A property that callback functions can be added to. When a callback property changes value, each callback function is called with information about the state change. Otherwise, callback properties behave just like normal instance variables. CallbackProperties must be defined at the class level. Use the helper function :func:`~echo.add_callback` to attach a callback to a specific instance of a class with CallbackProperties Parameters ---------- default The initial value for the property docstring : str The docstring for the property getter, setter : func Custom getter and setter functions (advanced) """ def __init__(self, default=None, docstring=None, getter=None, setter=None): """ :param default: The initial value for the property """ self._default = default self._validators = WeakKeyDictionary() self._2arg_validators = WeakKeyDictionary() self._callbacks = WeakKeyDictionary() self._2arg_callbacks = WeakKeyDictionary() self._disabled = WeakKeyDictionary() self._values = WeakKeyDictionary() if getter is None: getter = self._default_getter if setter is None: setter = self._default_setter self._getter = getter self._setter = setter if docstring is not None: self.__doc__ = docstring def _default_getter(self, instance, owner=None): return self._values.get(instance, self._default) def _default_setter(self, instance, value): self._values.__setitem__(instance, value) def __get__(self, instance, owner=None): if instance is None: return self return self._getter(instance) def __set__(self, instance, value): try: old = self.__get__(instance) except AttributeError: # pragma: no cover old = None value = self._validate(instance, old, value) self._setter(instance, value) new = self.__get__(instance) if old != new: self.notify(instance, old, new) def setter(self, func): """ Method to use as a decorator, to mimic @property.setter """ self._setter = func return self def _get_full_info(self, instance): # Some callback subclasses may contain additional info in addition # to the main value, and we need to use this full information when # comparing old and new 'values', so this method is used in that # case. The result should be a tuple where the first item is the # actual primary value of the property and the second item is any # additional data to use in the comparison. # Note that we need to make sure we convert any list here to a tuple # to make sure the value is immutable, otherwise comparisons of # old != new will not show any difference (since the list can still) # be modified in-place value = self.__get__(instance) if isinstance(value, list): value = tuple(value) return value, None def notify(self, instance, old, new): """ Call all callback functions with the current value Each callback will either be called using callback(new) or callback(old, new) depending on whether ``echo_old`` was set to `True` when calling :func:`~echo.add_callback` Parameters ---------- instance The instance to consider old The old value of the property new The new value of the property """ if not self.enabled(instance): return iterators = [] if container := self._callbacks.get(instance, None): iterators.append((cb, priority, 1) for cb, priority in container.iterator(priority=True, sort=False)) if container := self._2arg_callbacks.get(instance, None): iterators.append((cb, priority, 2) for cb, priority in container.iterator(priority=True, sort=False)) for callback, _priority, args in sorted(chain(*iterators), key=lambda x: x[1], reverse=True): if args == 1: callback(new) else: callback(old, new) def _validate(self, instance, old, new): """ Call all validators. Each validator will either be called using validator(new) or validator(old, new) depending on whether ``echo_old`` was set to `True` when calling :func:`~echo.add_callback` Parameters ---------- instance The instance to consider old The old value of the property new The new value of the property """ # Note: validators can't be delayed so we don't check for # enabled/disabled as in notify() for cback in self._validators.get(instance, []): new = cback(new) for cback in self._2arg_validators.get(instance, []): new = cback(old, new) return new def disable(self, instance): """ Disable callbacks for a specific instance """ self._disabled[instance] = True def enable(self, instance): """ Enable previously-disabled callbacks for a specific instance """ self._disabled[instance] = False def enabled(self, instance): return not self._disabled.get(instance, False) def add_callback(self, instance, func, echo_old=False, priority=0, validator=False): """ Add a callback to a specific instance that manages this property Parameters ---------- instance The instance to add the callback to func : func The callback function to add echo_old : bool, optional If `True`, the callback function will be invoked with both the old and new values of the property, as ``func(old, new)``. If `False` (the default), will be invoked as ``func(new)`` priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). validator : bool, optional Whether the callback is a validator, which is a special kind of callback that gets called *before* the property is set. The validator can return a modified value (for example it can be used to change the types of values or change properties in-place) or it can also raise an exception. """ if validator: if echo_old: self._2arg_validators.setdefault(instance, CallbackContainer()).append(func, priority=priority) else: self._validators.setdefault(instance, CallbackContainer()).append(func, priority=priority) else: if echo_old: self._2arg_callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) else: self._callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) @property def _all_callbacks(self): return [self._validators, self._2arg_validators, self._callbacks, self._2arg_callbacks] def remove_callback(self, instance, func): """ Remove a previously-added callback Parameters ---------- instance The instance to detach the callback from func : func The callback function to remove """ for cb in self._all_callbacks: if instance not in cb: continue if func in cb[instance]: cb[instance].remove(func) return else: raise ValueError(f"Callback function not found: {func}") def clear_callbacks(self, instance): """ Remove all callbacks on this property. """ for cb in self._all_callbacks: if instance in cb: cb[instance].clear() if instance in self._disabled: self._disabled.pop(instance) class HasCallbackProperties: """ A class that adds functionality to subclasses that use callback properties. """ def __init__(self): from .containers import DictCallbackProperty, ListCallbackProperty self._global_callbacks = CallbackContainer() self._ignored_properties = set() self._delayed_properties = {} self._delay_global_calls = {} self._callback_wrappers = {} self._notify_context_managers = [] for prop_name, prop in self.iter_callback_properties(): if isinstance(prop, ListCallbackProperty | DictCallbackProperty): prop.add_callback(self, self._notify_global_listordict) def _ignore_global_callbacks(self, properties): # This is to allow ignore_callbacks to work for global callbacks self._ignored_properties.update(properties) def _unignore_global_callbacks(self, properties): # Once this is called, we simply remove properties from _ignored_properties # and don't call the callbacks. This is used by ignore_callback self._ignored_properties -= set(properties) def _delay_global_callbacks(self, properties): # This is to allow delay_callback to still have an effect in delaying # global callbacks. We set _delayed_properties to a dictionary of the # values at the point at which the callbacks are delayed. self._delayed_properties.update(properties) def _process_delayed_global_callbacks(self, properties): # Once this is called, the global callbacks are called once each with # a dictionary of the current values of properties that have been # resumed. kwargs = {} for prop, new_value in properties.items(): old_value = self._delayed_properties.pop(prop) if old_value != new_value: kwargs[prop] = new_value[0] self._notify_global(**kwargs) def _notify_global_listordict(self, *args): from .containers import DictCallbackProperty, ListCallbackProperty properties = {} for prop_name, prop in self.iter_callback_properties(): if isinstance(prop, ListCallbackProperty | DictCallbackProperty): callback_listordict = getattr(self, prop_name) if callback_listordict is args[0]: properties[prop_name] = callback_listordict break self._notify_global(**properties) def _get_aliases_for(self, name): """ Return a list of alias names that point to the given property name. """ return [ attr_name for attr_name in dir(type(self)) if self.is_alias(attr_name) and getattr(type(self), attr_name)._target == name ] def _notify_global(self, **kwargs): # Add aliases for any properties being notified (for backward compatibility) kwargs.update( {alias: value for prop_name, value in kwargs.items() for alias in self._get_aliases_for(prop_name)} ) for prop in set(self._delayed_properties) | set(self._ignored_properties): if prop in kwargs: kwargs.pop(prop) if len(kwargs) > 0: for callback in self._global_callbacks: callback(**kwargs) def __setattr__(self, attribute, value): # Trigger global callbacks for callback properties, but not aliases # (aliases will trigger via the target property they redirect to) is_callback = self.is_callback_property(attribute) and not self.is_alias(attribute) if is_callback: previous_value = getattr(self, attribute, None) super().__setattr__(attribute, value) if is_callback and value != previous_value: self._notify_global(**{attribute: value}) def add_callback(self, name, callback, echo_old=False, priority=0, validator=False): """ Add a callback that gets triggered when a callback property of the class changes. Parameters ---------- name : str The instance to add the callback to. callback : func The callback function to add echo_old : bool, optional If `True`, the callback function will be invoked with both the old and new values of the property, as ``callback(old, new)``. If `False` (the default), will be invoked as ``callback(new)`` priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). validator : bool, optional Whether the callback is a validator, which is a special kind of callback that gets called *before* the property is set. The validator can return a modified value (for example it can be used to change the types of values or change properties in-place) or it can also raise an exception.""" if self.is_callback_property(name): prop = getattr(type(self), name) if self.is_alias(name): prop._warn() prop = prop._target_property prop.add_callback(self, callback, echo_old=echo_old, priority=priority, validator=validator) else: raise TypeError(f"attribute '{name}' is not a callback property") def remove_callback(self, name, callback): """ Remove a previously-added callback Parameters ---------- name : str The instance to remove the callback from. func : func The callback function to remove """ if self.is_callback_property(name): prop = getattr(type(self), name) if self.is_alias(name): prop._warn() prop = prop._target_property try: prop.remove_callback(self, callback) except ValueError: # pragma: nocover pass # Be forgiving if callback was already removed before else: raise TypeError(f"attribute '{name}' is not a callback property") def add_global_callback(self, callback): """ Add a global callback function, which is a callback that gets triggered when any callback properties on the class change. Parameters ---------- callback : func The callback function to add """ self._global_callbacks.append(callback) def remove_global_callback(self, callback): """ Remove a global callback function. Parameters ---------- callback : func The callback function to remove """ self._global_callbacks.remove(callback) def is_callback_property(self, name): """ Whether a property (identified by name) is a callback property. Returns True for both CallbackProperty and CallbackPropertyAlias. Parameters ---------- name : str The name of the property to check """ prop = getattr(type(self), name, None) return isinstance(prop, CallbackProperty | CallbackPropertyAlias) def is_alias(self, name): """ Whether a property (identified by name) is a callback property alias. Parameters ---------- name : str The name of the property to check """ prop = getattr(type(self), name, None) return isinstance(prop, CallbackPropertyAlias) def iter_callback_properties(self): """ Iterator to loop over all callback properties. Note: This does not include CallbackPropertyAlias instances, only actual CallbackProperty instances. """ for name in dir(self): prop = getattr(type(self), name, None) if isinstance(prop, CallbackProperty): yield name, prop def callback_properties(self): """ Return a list of all callback property names. Note: This does not include CallbackPropertyAlias instances, only actual CallbackProperty instances. """ return [name for name, _ in self.iter_callback_properties()] def clear_callbacks(self): """ Remove all global and property-specific callbacks. """ self._global_callbacks.clear() for name, prop in self.iter_callback_properties(): prop.clear_callbacks(self) def _resolve_callback_property(instance, prop): """ Resolve a property name to its CallbackProperty, handling aliases. If the property is a CallbackPropertyAlias, follows it to the target property and emits a deprecation warning if applicable. Returns ------- tuple (resolved_name, callback_property) - the resolved property name and the actual CallbackProperty object """ p = getattr(type(instance), prop) if isinstance(p, CallbackPropertyAlias): p._warn() return p._target, p._target_property return prop, p def add_callback(instance, prop, callback, echo_old=False, priority=0, validator=False): """ Attach a callback function to a property in an instance Parameters ---------- instance The instance to add the callback to prop : str Name of callback property in `instance` callback : func The callback function to add echo_old : bool, optional If `True`, the callback function will be invoked with both the old and new values of the property, as ``func(old, new)``. If `False` (the default), will be invoked as ``func(new)`` priority : int, optional This can optionally be used to force a certain order of execution of callbacks (larger values indicate a higher priority). validator : bool, optional Whether the callback is a validator, which is a special kind of callback that gets called *before* the property is set. The validator can return a modified value (for example it can be used to change the types of values or change properties in-place) or it can also raise an exception. Examples -------- :: class Foo: bar = CallbackProperty(0) def callback(value): pass f = Foo() add_callback(f, 'bar', callback) """ prop, p = _resolve_callback_property(instance, prop) if not isinstance(p, CallbackProperty): raise TypeError(f"{prop} is not a CallbackProperty") p.add_callback(instance, callback, echo_old=echo_old, priority=priority, validator=validator) def remove_callback(instance, prop, callback): """ Remove a callback function from a property in an instance Parameters ---------- instance The instance to detach the callback from prop : str Name of callback property in `instance` callback : func The callback function to remove """ prop, p = _resolve_callback_property(instance, prop) if not isinstance(p, CallbackProperty): raise TypeError(f"{prop} is not a CallbackProperty") p.remove_callback(instance, callback) def callback_property(getter): """ A decorator to build a CallbackProperty. This is used by wrapping a getter method, similar to the use of @property:: class Foo(object): @callback_property def x(self): return self._x @x.setter def x(self, value): self._x = value In simple cases with no getter or setter logic, it's easier to create a :class:`~echo.CallbackProperty` directly:: class Foo(object); x = CallbackProperty(initial_value) """ cb = CallbackProperty(getter=getter) cb.__doc__ = getter.__doc__ return cb class delay_callback: """ Delay any callback functions from one or more callback properties This is a context manager. Within the context block, no callbacks will be issued. Each callback will be called once on exit Parameters ---------- instance An instance object with callback properties *props : str One or more properties within instance to delay Examples -------- :: with delay_callback(foo, 'bar', 'baz'): f.bar = 20 f.baz = 30 f.bar = 10 print('done') # callbacks triggered at this point, if needed """ # Class-level registry of properties and how many times the callbacks have # been delayed. The idea is that when nesting calls to delay_callback, the # delay count is increased, and every time __exit__ is called, the count is # decreased, and once the count reaches zero, the callback is triggered. delay_count = {} old_values = {} def __init__(self, instance, *props): self.instance = instance # Resolve any aliases to their target property names self.props = tuple(_resolve_callback_property(instance, p)[0] for p in props) def __enter__(self): delay_props = {} for prop in self.props: p = getattr(type(self.instance), prop) if not isinstance(p, CallbackProperty): raise TypeError(f"{prop} is not a CallbackProperty") if (self.instance, prop) not in self.delay_count: self.delay_count[self.instance, prop] = 1 self.old_values[self.instance, prop] = p._get_full_info(self.instance) delay_props[prop] = p._get_full_info(self.instance) else: self.delay_count[self.instance, prop] += 1 p.disable(self.instance) if isinstance(self.instance, HasCallbackProperties): self.instance._delay_global_callbacks(delay_props) def __exit__(self, *args): resume_props = {} notifications = [] for prop in self.props: p = getattr(type(self.instance), prop) if not isinstance(p, CallbackProperty): # pragma: no cover raise TypeError(f"{prop} is not a CallbackProperty") if self.delay_count[self.instance, prop] > 1: self.delay_count[self.instance, prop] -= 1 else: self.delay_count.pop((self.instance, prop)) old = self.old_values.pop((self.instance, prop)) p.enable(self.instance) new = p._get_full_info(self.instance) if old != new: notifications.append((p, (self.instance, old[0], new[0]))) resume_props[prop] = new if isinstance(self.instance, HasCallbackProperties): self.instance._process_delayed_global_callbacks(resume_props) if cms := getattr(self.instance, "_notify_context_managers", None): with ExitStack() as stack: for cm_factory in cms: stack.enter_context(cm_factory()) for p, args in notifications: p.notify(*args) else: for p, args in notifications: p.notify(*args) @contextmanager def ignore_callback(instance, *props): """ Temporarily ignore any callbacks from one or more callback properties This is a context manager. Within the context block, no callbacks will be issued. In contrast with `delay_callback`, no callbacks will be called on exiting the context manager Parameters ---------- instance An instance object with callback properties *props : str One or more properties within instance to ignore Examples -------- :: with ignore_callback(foo, 'bar', 'baz'): f.bar = 20 f.baz = 30 f.bar = 10 print('done') # no callbacks called """ # Resolve any aliases to their target property names props = tuple(_resolve_callback_property(instance, p)[0] for p in props) for prop in props: p = getattr(type(instance), prop) if not isinstance(p, CallbackProperty): raise TypeError(f"{prop} is not a CallbackProperty") p.disable(instance) if isinstance(instance, HasCallbackProperties): instance._ignore_global_callbacks(props) yield for prop in props: p = getattr(type(instance), prop) assert isinstance(p, CallbackProperty) p.enable(instance) if isinstance(instance, HasCallbackProperties): instance._unignore_global_callbacks(props) class keep_in_sync: def __init__(self, instance1, prop1, instance2, prop2): self.instance1 = weakref.ref(instance1, self.disable_syncing) self.prop1 = prop1 self.instance2 = weakref.ref(instance2, self.disable_syncing) self.prop2 = prop2 self._syncing = False self.enabled = False self.enable_syncing() def prop1_from_prop2(self, value): if not self._syncing: self._syncing = True setattr(self.instance1(), self.prop1, getattr(self.instance2(), self.prop2)) self._syncing = False def prop2_from_prop1(self, value): if not self._syncing: self._syncing = True setattr(self.instance2(), self.prop2, getattr(self.instance1(), self.prop1)) self._syncing = False def enable_syncing(self, *args): if self.enabled: return add_callback(self.instance1(), self.prop1, self.prop2_from_prop1) add_callback(self.instance2(), self.prop2, self.prop1_from_prop2) self.enabled = True def disable_syncing(self, *args): if not self.enabled: return if self.instance1() is not None: remove_callback(self.instance1(), self.prop1, self.prop2_from_prop1) if self.instance2() is not None: remove_callback(self.instance2(), self.prop2, self.prop1_from_prop2) self.enabled = False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6150398 echo-0.16.0/echo/qt/0000755000175100017510000000000015252526356013537 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.742173 echo-0.16.0/echo/qt/__init__.py0000644000175100017510000000015415252526320015637 0ustar00runnerrunnertry: from .connect import * # noqa from .autoconnect import * # noqa except ImportError: pass ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7422736 echo-0.16.0/echo/qt/autoconnect.py0000644000175100017510000001256715252526320016435 0ustar00runnerrunnerfrom .connect import ( connect_button, connect_checkable_button, connect_combo_data, connect_combo_selection, connect_combo_text, connect_datetime, connect_float_text, connect_list_selection, connect_text, connect_value, ) __all__ = ["autoconnect_callbacks_to_qt"] HANDLERS = {} HANDLERS["value"] = connect_value HANDLERS["valuetext"] = connect_float_text HANDLERS["bool"] = connect_checkable_button HANDLERS["text"] = connect_text HANDLERS["combodata"] = connect_combo_data HANDLERS["combotext"] = connect_combo_text HANDLERS["button"] = connect_button HANDLERS["combosel"] = connect_combo_selection HANDLERS["listsel"] = connect_list_selection HANDLERS["datetime"] = connect_datetime def autoconnect_callbacks_to_qt(instance, widget, connect_kwargs={}): """ Given a class instance with callback properties and a Qt widget/window, connect callback properties to Qt widgets automatically. The matching is done based on the objectName of the Qt widgets. Qt widgets that need to be connected should be named using the syntax ``type_name`` where ``type`` describes the kind of matching to be done, and ``name`` matches the name of a callback property. By default, the types can be: * ``value``: the callback property is linked to a Qt widget that has ``value`` and ``setValue`` methods. Note that for this type, two additional keyword arguments can be specified using ``connect_kwargs`` (see below): these are ``value_range``, which is used for cases where the Qt widget is e.g. a slider which has a range of values, and you want to map this range of values onto a different range for the callback property, and the second is ``log``, which can be set to `True` if this mapping should be done in log space. * ``valuetext``: the callback property is linked to a Qt widget that has ``text`` and ``setText`` methods, and the text is set to a string representation of the value. Note that for this type, an additional argument ``fmt`` can be provided, which gives either the format to use using the ``{}`` syntax, or should be a function that takes a value and returns a string. Optionally, if the Qt widget supports the ``editingFinished`` signal, this signal is connected to the callback property too. * ``bool``: the callback property is linked to a Qt widget that has ``isChecked`` and ``setChecked`` methods, such as a checkable button. * ``text``: the callback property is linked to a Qt widget that has ``text`` and ``setText`` methods. Optionally, if the Qt widget supports the ``editingFinished`` signal, this signal is connected to the callback property too. * ``combodata``: the callback property is linked to a QComboBox based on the ``userData`` of the entries in the combo box. * ``combotext``: the callback property is linked to a QComboBox based on the label of the entries in the combo box. * ``datetime``: the callback property is linked to a QDateTimeEdit. Note that this connection will also work for the more specific QDateEdit and QTimeEdit widgets, as they are subclasses of QDateTimeEdit. Applications can also define additional mappings between type and auto-linking. To do this, simply add a new entry to the ``HANDLERS`` object:: >>> echo.qt.autoconnect import HANDLERS >>> HANDLERS['color'] = connect_color The handler function (``connect_color`` in the example above) should take the following arguments: the instance the callback property is attached to, the name of the callback property, the Qt widget, and optionally some keyword arguments. When calling ``autoconnect_callbacks_to_qt``, you can specify ``connect_kwargs``, where each key should be a valid callback property name, and which gives any additional keyword arguments that can be taken by the connect functions, as described above. These include for example ``value_range``, ``log``, and ``fmt``. This function is especially useful when defining ui files, since widget objectNames can be easily set during the editing process. """ # We need a dictionary to store the returned connection handlers in cases # where these are defined. returned_handlers = {} for original_name in dir(widget): if original_name.startswith("_") or "_" not in original_name: continue # FIXME: this is a temorary workaround to allow multiple widgets to be # connected to a state attribute. if original_name.endswith("_"): full_name = original_name[:-1] else: full_name = original_name if "_" in full_name: wtype, wname = full_name.split("_", 1) if full_name in connect_kwargs: kwargs = connect_kwargs[full_name] elif wname in connect_kwargs: kwargs = connect_kwargs[wname] else: kwargs = {} if hasattr(instance, wname): if wtype in HANDLERS: child = getattr(widget, original_name) # NOTE: we need to use original_name here since we need a # unique key, and some wname values might be duplicate. returned_handlers[original_name] = HANDLERS[wtype](instance, wname, child, **kwargs) return returned_handlers ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7422736 echo-0.16.0/echo/qt/connect.py0000644000175100017510000005230615252526320015537 0ustar00runnerrunner# The functions in this module are used to connect callback properties to Qt # widgets. import math from datetime import datetime import numpy as np from qtpy import QtGui, QtWidgets from qtpy.QtCore import QDateTime, Qt from ..core import add_callback, remove_callback from ..selection import ChoiceSeparator, SelectionCallbackProperty __all__ = [ "connect_checkable_button", "connect_text", "connect_combo_data", "connect_combo_text", "connect_float_text", "connect_value", "connect_combo_selection", "connect_list_selection", "connect_datetime", "BaseConnection", ] class UserDataWrapper: def __init__(self, data): self.data = data class BaseConnection: def __init__(self, instance, prop, widget): self._instance = instance self._prop = prop self._widget = widget class connect_checkable_button(BaseConnection): """ Connect a boolean callback property with a Qt button widget. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QtWidget The Qt widget to connect. This should implement the ``setChecked`` method and the ``toggled`` signal. """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def update_widget(self, value): self._widget.setChecked(value) def update_prop(self, value): setattr(self._instance, self._prop, value) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.toggled.connect(self.update_prop) self._widget.setChecked(getattr(self._instance, self._prop) or False) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.toggled.disconnect(self.update_prop) class connect_text(BaseConnection): """ Connect a string callback property with a Qt widget containing text. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QtWidget The Qt widget to connect. This should implement the ``setText`` and ``text`` methods as well optionally the ``editingFinished`` signal. """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def update_prop(self): value = self._widget.text() setattr(self._instance, self._prop, value) def update_widget(self, value): if hasattr(self._widget, "editingFinished"): self._widget.blockSignals(True) self._widget.setText(value) self._widget.blockSignals(False) self._widget.editingFinished.emit() else: self._widget.setText(value) def connect(self): add_callback(self._instance, self._prop, self.update_widget) try: self._widget.editingFinished.connect(self.update_prop) except AttributeError: pass self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) try: self._widget.editingFinished.disconnect(self.update_prop) except AttributeError: pass class connect_combo_data(BaseConnection): """ Connect a callback property with a QComboBox widget based on the userData. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QComboBox The combo box to connect. See Also -------- connect_combo_text: connect a callback property with a QComboBox widget based on the text. """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def update_widget(self, value): try: idx = _find_combo_data(self._widget, value) except ValueError: if value is None: idx = -1 else: raise self._widget.setCurrentIndex(idx) def update_prop(self, idx): if idx == -1: setattr(self._instance, self._prop, None) else: data_wrapper = self._widget.itemData(idx) if data_wrapper is None: setattr(self._instance, self._prop, None) else: setattr(self._instance, self._prop, data_wrapper.data) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.disconnect(self.update_prop) class connect_combo_text(BaseConnection): """ Connect a callback property with a QComboBox widget based on the text. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QComboBox The combo box to connect. See Also -------- connect_combo_data: connect a callback property with a QComboBox widget based on the userData. """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def update_widget(self, value): try: idx = _find_combo_text(self._widget, value) except ValueError: if value is None: idx = -1 else: raise self._widget.setCurrentIndex(idx) def update_prop(self, idx): if idx == -1: setattr(self._instance, self._prop, None) else: setattr(self._instance, self._prop, self._widget.itemText(idx)) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.disconnect(self.update_prop) class connect_float_text(BaseConnection): """ Connect a numerical callback property with a Qt widget containing text. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QtWidget The Qt widget to connect. This should implement the ``setText`` and ``text`` methods as well optionally the ``editingFinished`` signal. fmt : str or func This should be either a format string (in the ``{}`` notation), or a function that takes a number and returns a string. """ def __init__(self, instance, prop, widget, fmt="{:g}"): super().__init__(instance, prop, widget) if callable(fmt): format_func = fmt else: def format_func(x): try: return fmt.format(x) except ValueError: return str(x) self._format_func = format_func self.connect() def update_prop(self): value = self._widget.text() try: value = float(value) except ValueError: try: value = np.datetime64(value) except Exception: value = 0 setattr(self._instance, self._prop, value) def update_widget(self, value): if value is None: value = 0.0 self._widget.setText(self._format_func(value)) def connect(self): add_callback(self._instance, self._prop, self.update_widget) try: self._widget.editingFinished.connect(self.update_prop) except AttributeError: pass self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) try: self._widget.editingFinished.disconnect(self.update_prop) except AttributeError: pass class connect_value(BaseConnection): """ Connect a numerical callback property with a Qt widget representing a value. Parameters ---------- instance : object The class instance that the callback property is attached to prop : str The name of the callback property widget : QtWidget The Qt widget to connect. This should implement the ``setText`` and ``text`` methods as well optionally the ``editingFinished`` signal. value_range : iterable, optional A pair of two values representing the true range of values (since Qt widgets such as sliders can only have values in certain ranges). log : bool, optional Whether the Qt widget value should be mapped to the log of the callback property. """ def __init__(self, instance, prop, widget, value_range=None, log=False): super().__init__(instance, prop, widget) if log: if value_range is None: raise ValueError("log option can only be set if value_range is given") else: self._value_range = math.log10(value_range[0]), math.log10(value_range[1]) else: self._value_range = value_range self._log = log self.connect() def update_prop(self): value = self._widget.value() if self._value_range is not None: imin, imax = self._widget.minimum(), self._widget.maximum() value = (value - imin) / (imax - imin) * (self._value_range[1] - self._value_range[0]) + self._value_range[ 0 ] if self._log: value = 10**value setattr(self._instance, self._prop, value) def update_widget(self, value): if value is None: self._widget.setValue(0) return if self._log: value = math.log10(value) if self._value_range is not None: imin, imax = self._widget.minimum(), self._widget.maximum() value = (value - self._value_range[0]) / (self._value_range[1] - self._value_range[0]) * ( imax - imin ) + imin if isinstance(self._widget, QtWidgets.QSlider | QtWidgets.QSpinBox): self._widget.setValue(int(value)) else: self._widget.setValue(value) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.valueChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.valueChanged.disconnect(self.update_prop) class connect_button(BaseConnection): """ Connect a button with a callback method Parameters ---------- instance : object The class instance that the callback method is attached to prop : str The name of the callback method widget : QtWidget The Qt widget to connect. This should implement the ``clicked`` method """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def connect(self): self._widget.clicked.connect(getattr(self._instance, self._prop)) def disconnect(self): self._widget.clicked.disconnect(self.update_prop) def _find_combo_data(widget, value): """ Returns the index in a combo box where itemData == value Raises a ValueError if data is not found """ # Here we check that the result is True, because some classes may overload # == and return other kinds of objects whether true or false. for idx in range(widget.count()): if widget.itemData(idx) is not None: if isinstance(widget.itemData(idx), UserDataWrapper): if widget.itemData(idx).data is value or (widget.itemData(idx).data == value) is True: return idx else: if widget.itemData(idx) is value or (widget.itemData(idx) == value) is True: return idx else: raise ValueError(f"{value} not found in combo box") def _find_combo_text(widget, value): """ Returns the index in a combo box where text == value Raises a ValueError if data is not found """ i = widget.findText(value) if i == -1: raise ValueError(f"{value} not found in combo box") else: return i class connect_combo_selection(BaseConnection): def __init__(self, instance, prop, widget): prop_obj = getattr(type(instance), prop) # Handle aliases - resolve to target property for type checking if hasattr(prop_obj, "_target_property") and prop_obj._target_property is not None: prop_obj = prop_obj._target_property if not isinstance(prop_obj, SelectionCallbackProperty): raise TypeError("connect_combo_selection requires a SelectionCallbackProperty") super().__init__(instance, prop, widget) self.connect() def update_widget(self, value): # Update choices in the combo box combo_data = [self._widget.itemData(idx) for idx in range(self._widget.count())] combo_text = [self._widget.itemText(idx) for idx in range(self._widget.count())] choices = getattr(type(self._instance), self._prop).get_choices(self._instance) choice_labels = getattr(type(self._instance), self._prop).get_choice_labels(self._instance) if combo_data == choices and combo_text == choice_labels: choices_updated = False else: self._widget.blockSignals(True) self._widget.clear() if len(choices) == 0: return combo_model = self._widget.model() for index, (label, choice) in enumerate(zip(choice_labels, choices)): self._widget.addItem(label, userData=UserDataWrapper(choice)) # We interpret None data as being disabled rows (used for headers) if isinstance(choice, ChoiceSeparator): item = combo_model.item(index) palette = self._widget.palette() item.setFlags(item.flags() & ~(Qt.ItemIsSelectable | Qt.ItemIsEnabled)) item.setData(palette.color(QtGui.QPalette.Disabled, QtGui.QPalette.Text)) choices_updated = True # Update current selection try: idx = _find_combo_data(self._widget, value) except ValueError: if value is None: idx = -1 else: raise if idx == self._widget.currentIndex() and not choices_updated: return self._widget.setCurrentIndex(idx) self._widget.blockSignals(False) self._widget.currentIndexChanged.emit(idx) def update_prop(self, idx): if idx == -1: setattr(self._instance, self._prop, None) else: data_wrapper = self._widget.itemData(idx) if data_wrapper is None: setattr(self._instance, self._prop, None) else: setattr(self._instance, self._prop, data_wrapper.data) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.currentIndexChanged.disconnect(self.update_prop) class connect_list_selection(BaseConnection): def __init__(self, instance, prop, widget): """ Connect a SelectionCallbackProperty with a QListWidget that supports single-item selection. """ prop_obj = getattr(type(instance), prop) # Handle aliases - resolve to target property for type checking if hasattr(prop_obj, "_target_property") and prop_obj._target_property is not None: prop_obj = prop_obj._target_property if not isinstance(prop_obj, SelectionCallbackProperty): raise TypeError("connect_list_selection requires a SelectionCallbackProperty") super().__init__(instance, prop, widget) self.connect() def update_widget(self, value, force=False): items = [self._widget.item(idx) for idx in range(self._widget.count())] list_text = [item.text() for item in items] list_data = [item.data(Qt.UserRole) for item in items] list_data = [d.data if d is not None else d for d in list_data] choices = getattr(type(self._instance), self._prop).get_choices(self._instance) choice_labels = getattr(type(self._instance), self._prop).get_choice_labels(self._instance) for idx in range(len(choices)): if choices[idx] is value or (choices[idx] == value) is True: break else: idx = -1 self._widget.blockSignals(True) choices_match = list_data == choices and list_text == choice_labels if force or not choices_match: self._widget.clear() if len(choices) == 0: self._widget.blockSignals(False) return for index, (label, choice) in enumerate(zip(choice_labels, choices)): item = QtWidgets.QListWidgetItem(label) item.setData(Qt.UserRole, UserDataWrapper(choice)) self._widget.addItem(item) # We interpret None data as being disabled rows (used for headers) if isinstance(choice, ChoiceSeparator): item.setFlags(item.flags() & ~(Qt.ItemIsSelectable | Qt.ItemIsEnabled)) if len(self._widget.selectedItems()) == 0: current_index = -1 else: selected_item = self._widget.selectedItems()[0] for current_index, item in enumerate(items): if item is selected_item: break else: current_index = -1 if idx == current_index and choices_match: self._widget.blockSignals(False) return self._widget.setCurrentItem(self._widget.item(idx)) self._widget.blockSignals(False) self._widget.itemSelectionChanged.emit() def update_prop(self): if len(self._widget.selectedItems()) == 0: setattr(self._instance, self._prop, None) else: data_wrapper = self._widget.selectedItems()[0].data(Qt.UserRole) if data_wrapper is None: setattr(self._instance, self._prop, None) else: setattr(self._instance, self._prop, data_wrapper.data) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.itemSelectionChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.itemSelectionChanged.disconnect(self.update_prop) class connect_datetime(BaseConnection): """ Connect a CallbackProperty to a QDateTimeEdit. Since QDateEdit and QTimeEdit are subclasses of QDateTimeEdit, this connection will work for those more specific widgets as well. """ def __init__(self, instance, prop, widget): super().__init__(instance, prop, widget) self.connect() def update_prop(self): qdatetime = self._widget.dateTime().toUTC() value = np.datetime64(qdatetime.toPython()) setattr(self._instance, self._prop, value) def update_widget(self, value): if value is None: value = np.datetime64("now") dt = value.item() # datetime64::item can return a date # If this happens, we use midnight as our time # (datetime is a subclass of date so we need to check this way) if not isinstance(dt, datetime): date = dt time = datetime.min.time() else: date = dt.date() time = dt.time() qdatetime = QDateTime(date, time, Qt.TimeSpec.UTC).toTimeSpec(self._widget.timeSpec()) self._widget.setDateTime(qdatetime) def connect(self): add_callback(self._instance, self._prop, self.update_widget) self._widget.dateTimeChanged.connect(self.update_prop) self._widget.dateChanged.connect(self.update_prop) self._widget.timeChanged.connect(self.update_prop) self.update_widget(getattr(self._instance, self._prop)) def disconnect(self): remove_callback(self._instance, self._prop, self.update_widget) self._widget.dateTimeChanged.disconnect(self.update_prop) self._widget.dateChanged.disconnect(self.update_prop) self._widget.timeChanged.disconnect(self.update_prop) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6164236 echo-0.16.0/echo/qt/tests/0000755000175100017510000000000015252526356014701 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7422736 echo-0.16.0/echo/qt/tests/__init__.py0000644000175100017510000000000015252526320016767 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/qt/tests/helpers.py0000644000175100017510000000123415252526320016704 0ustar00runnerrunner__all__ = [ "PYQT5_INSTALLED", "PYQT6_INSTALLED", "PYSIDE2_INSTALLED", "PYSIDE6_INSTALLED", "QTPY_INSTALLED", "QT_INSTALLED", "SKIP_QT_TEST", ] def package_installed(package): from importlib.util import find_spec return find_spec(package) is not None PYQT5_INSTALLED = package_installed("PyQt5") PYQT6_INSTALLED = package_installed("PyQt6") PYSIDE2_INSTALLED = package_installed("PySide2") PYSIDE6_INSTALLED = package_installed("PySide6") QTPY_INSTALLED = package_installed("qtpy") QT_INSTALLED = PYQT5_INSTALLED or PYQT6_INSTALLED or PYSIDE2_INSTALLED or PYSIDE6_INSTALLED SKIP_QT_TEST = not (QTPY_INSTALLED and QT_INSTALLED) ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/qt/tests/test_autoconnect.py0000644000175100017510000001064515252526320020631 0ustar00runnerrunnerfrom datetime import datetime import pytest from numpy import datetime64 from echo import CallbackProperty from echo.qt.tests.helpers import SKIP_QT_TEST if SKIP_QT_TEST: pytest.skip(allow_module_level=True) from qtpy import QtGui, QtWidgets from qtpy.QtCore import QDateTime, Qt from echo.qt.autoconnect import autoconnect_callbacks_to_qt from echo.qt.connect import UserDataWrapper def test_autoconnect_callbacks_to_qt(): class Data: pass data1 = Data() data2 = Data() class CustomWidget(QtWidgets.QWidget): def __init__(self, parent=None): super().__init__(parent=parent) self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) self.combotext_planet = QtWidgets.QComboBox(objectName="combotext_planet") self.layout.addWidget(self.combotext_planet) self.combotext_planet.addItem("earth") self.combotext_planet.addItem("mars") self.combotext_planet.addItem("jupiter") self.combodata_dataset = QtWidgets.QComboBox(objectName="combodata_dataset") self.layout.addWidget(self.combodata_dataset) self.combodata_dataset.addItem("data1", UserDataWrapper(data1)) self.combodata_dataset.addItem("data2", UserDataWrapper(data2)) self.text_name = QtWidgets.QLineEdit(objectName="text_name") self.layout.addWidget(self.text_name) self.valuetext_age = QtWidgets.QLineEdit(objectName="valuetext_age") self.layout.addWidget(self.valuetext_age) self.value_height = QtWidgets.QSlider(objectName="value_height") self.value_height.setMinimum(0) self.value_height.setMaximum(10) self.layout.addWidget(self.value_height) self.bool_log = QtWidgets.QToolButton(objectName="bool_log") self.bool_log.setCheckable(True) self.layout.addWidget(self.bool_log) self.datetime_dob = QtWidgets.QDateTimeEdit(objectName="datetime_dob") self.datetime_dob.setTimeSpec(Qt.TimeSpec.UTC) self.layout.addWidget(self.datetime_dob) class Person: planet = CallbackProperty() dataset = CallbackProperty() name = CallbackProperty() age = CallbackProperty() height = CallbackProperty() log = CallbackProperty() dob = CallbackProperty() widget = CustomWidget() person = Person() connect_kwargs = {"height": {"value_range": (0, 100)}, "age": {"fmt": "{:.2f}"}} c1 = autoconnect_callbacks_to_qt(person, widget, connect_kwargs=connect_kwargs) # noqa # Check that modifying things in the Qt widget updates the callback properties widget.combotext_planet.setCurrentIndex(2) assert person.planet == "jupiter" widget.combodata_dataset.setCurrentIndex(1) assert person.dataset is data2 widget.text_name.setText("Lovelace") widget.text_name.editingFinished.emit() assert person.name == "Lovelace" widget.valuetext_age.setText("76") widget.valuetext_age.editingFinished.emit() assert person.age == 76 widget.value_height.setValue(7) assert person.height == 70 widget.bool_log.setChecked(True) assert person.log dob = datetime(2000, 1, 1, 11, 52, 6) qdob = QDateTime(dob.date(), dob.time(), Qt.TimeSpec.UTC) widget.datetime_dob.setDateTime(qdob) assert person.dob.item() == dob # Check that modifying the callback properties updates the Qt widget person.planet = "mars" assert widget.combotext_planet.currentIndex() == 1 person.dataset = data1 assert widget.combodata_dataset.currentIndex() == 0 person.name = "Curie" assert widget.text_name.text() == "Curie" person.age = 66.3 assert widget.valuetext_age.text() == "66.30" person.height = 54 assert widget.value_height.value() == 5 person.log = False assert not widget.bool_log.isChecked() dob = datetime(2010, 2, 3, 16, 22, 11) person.dob = datetime64(dob) qdatetime = widget.datetime_dob.dateTime().toUTC() assert qdatetime.toPython() == person.dob.item() def test_autoconnect_with_empty_qt_item(): # The following test just makes sure that if a widget without children # is ever passed to autoconnect_callbacks_to_qt, things don't crash widget = QtGui.QPalette() class Person: name = CallbackProperty() person = Person() autoconnect_callbacks_to_qt(person, widget) ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/qt/tests/test_connect.py0000644000175100017510000001225115252526320017733 0ustar00runnerrunnerfrom datetime import datetime from unittest.mock import MagicMock import pytest from numpy import datetime64 from echo import CallbackProperty from echo.qt.tests.helpers import SKIP_QT_TEST if SKIP_QT_TEST: pytest.skip(allow_module_level=True) from qtpy import QtWidgets from qtpy.QtCore import QDateTime, Qt from echo.qt.connect import ( UserDataWrapper, connect_button, connect_checkable_button, connect_combo_data, connect_combo_text, connect_datetime, connect_float_text, connect_text, connect_value, ) def test_connect_checkable_button(): class Test: a = CallbackProperty() b = CallbackProperty(True) t = Test() box1 = QtWidgets.QCheckBox() c1 = connect_checkable_button(t, "a", box1) # noqa box1.setChecked(True) assert t.a box1.setChecked(False) assert not t.a t.a = True assert box1.isChecked() t.a = False assert not box1.isChecked() # Make sure that the default value of the callback property is recognized box2 = QtWidgets.QCheckBox() connect_checkable_button(t, "b", box2) assert box2.isChecked() def test_connect_text(): class Test: a = CallbackProperty() b = CallbackProperty() t = Test() box = QtWidgets.QLineEdit() c1 = connect_text(t, "a", box) # noqa label = QtWidgets.QLabel() c2 = connect_text(t, "b", label) # noqa box.setText("test1") box.editingFinished.emit() assert t.a == "test1" t.a = "test3" assert box.text() == "test3" t.b = "test4" assert label.text() == "test4" def test_connect_combo(): class Test: a = CallbackProperty() b = CallbackProperty() t = Test() combo = QtWidgets.QComboBox() combo.addItem("label1", UserDataWrapper(4)) combo.addItem("label2", UserDataWrapper(3.5)) c1 = connect_combo_text(t, "a", combo) # noqa c2 = connect_combo_data(t, "b", combo) # noqa combo.setCurrentIndex(1) assert t.a == "label2" assert t.b == 3.5 combo.setCurrentIndex(0) assert t.a == "label1" assert t.b == 4 combo.setCurrentIndex(-1) assert t.a is None assert t.b is None t.a = "label2" assert combo.currentIndex() == 1 t.a = "label1" assert combo.currentIndex() == 0 with pytest.raises(ValueError) as exc: t.a = "label3" assert exc.value.args[0] == "label3 not found in combo box" t.a = None assert combo.currentIndex() == -1 t.b = 3.5 assert combo.currentIndex() == 1 t.b = 4 assert combo.currentIndex() == 0 with pytest.raises(ValueError) as exc: t.b = 2 assert exc.value.args[0] == "2 not found in combo box" t.b = None assert combo.currentIndex() == -1 def test_connect_float_text(): class Test: a = CallbackProperty() b = CallbackProperty() c = CallbackProperty() t = Test() line1 = QtWidgets.QLineEdit() line2 = QtWidgets.QLineEdit() line3 = QtWidgets.QLabel() def fmt_func(x): return str(int(round(x))) c1 = connect_float_text(t, "a", line1) # noqa c2 = connect_float_text(t, "b", line2, fmt="{:5.2f}") # noqa c3 = connect_float_text(t, "c", line3, fmt=fmt_func) # noqa for line in (line1, line2): line1.setText("1.0") line1.editingFinished.emit() assert t.a == 1.0 line1.setText("banana") line1.editingFinished.emit() assert t.a == 0.0 t.a = 3.0 assert line1.text() == "3" t.b = 5.211 assert line2.text() == " 5.21" t.c = -2.222 assert line3.text() == "-2" def test_connect_value(): class Test: a = CallbackProperty() b = CallbackProperty() c = CallbackProperty() t = Test() slider = QtWidgets.QSlider() slider.setMinimum(0) slider.setMaximum(100) c1 = connect_value(t, "a", slider) # noqa c2 = connect_value(t, "b", slider, value_range=(0, 10)) # noqa with pytest.raises(Exception) as exc: connect_value(t, "c", slider, log=True) assert exc.value.args[0] == "log option can only be set if value_range is given" c3 = connect_value(t, "c", slider, value_range=(0.01, 100), log=True) # noqa slider.setValue(25) assert t.a == 25 assert t.b == 2.5 assert t.c == 0.1 t.a = 30 assert slider.value() == 30 t.b = 8.5 assert slider.value() == 85 t.c = 10 assert slider.value() == 75 def test_connect_button(): class Example: a = MagicMock() e = Example() button = QtWidgets.QPushButton("OK") connect_button(e, "a", button) assert e.a.call_count == 0 button.clicked.emit() assert e.a.call_count == 1 def test_connect_datetime(): class Example: t = CallbackProperty() e = Example() widget = QtWidgets.QDateTimeEdit() widget.setTimeSpec(Qt.TimeSpec.UTC) conn = connect_datetime(e, "t", widget) # noqa dt = datetime(2010, 5, 7, 13, 15, 3) qdt = QDateTime(dt.date(), dt.time(), Qt.TimeSpec.UTC) widget.setDateTime(qdt) widget.dateTimeChanged.emit(qdt) assert dt == e.t.item() dt = datetime(2020, 3, 4, 7, 48, 16) e.t = datetime64(dt) assert widget.dateTime().toUTC().toPython() == dt ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/qt/tests/test_connect_combo_selection.py0000644000175100017510000001111715252526320023157 0ustar00runnerrunnerimport numpy as np import pytest from echo.alias import CallbackPropertyAlias from echo.core import CallbackProperty from echo.qt.tests.helpers import SKIP_QT_TEST from echo.selection import ChoiceSeparator, SelectionCallbackProperty if SKIP_QT_TEST: pytest.skip(allow_module_level=True) from qtpy import QtWidgets from echo.qt.connect import connect_combo_selection class Example: a = SelectionCallbackProperty(default_index=1) a_alias = CallbackPropertyAlias("a") b = CallbackProperty() def test_connect_combo_selection(): t = Example() a_prop = getattr(type(t), "a") a_prop.set_choices(t, [4, 3.5]) a_prop.set_display_func(t, lambda x: f"value: {x}") combo = QtWidgets.QComboBox() c1 = connect_combo_selection(t, "a", combo) # noqa assert combo.itemText(0) == "value: 4" assert combo.itemText(1) == "value: 3.5" assert combo.itemData(0).data == 4 assert combo.itemData(1).data == 3.5 combo.setCurrentIndex(1) assert t.a == 3.5 combo.setCurrentIndex(0) assert t.a == 4 combo.setCurrentIndex(-1) assert t.a is None t.a = 3.5 assert combo.currentIndex() == 1 t.a = 4 assert combo.currentIndex() == 0 with pytest.raises(ValueError) as exc: t.a = 2 assert exc.value.args[0] == "value 2 is not in valid choices: [4, 3.5]" t.a = None assert combo.currentIndex() == -1 # Changing choices should change Qt combo box. Let's first try with a case # in which there is a matching data value in the new combo box t.a = 3.5 assert combo.currentIndex() == 1 a_prop.set_choices(t, (4, 5, 3.5)) assert combo.count() == 3 assert t.a == 3.5 assert combo.currentIndex() == 2 assert combo.itemText(0) == "value: 4" assert combo.itemText(1) == "value: 5" assert combo.itemText(2) == "value: 3.5" assert combo.itemData(0).data == 4 assert combo.itemData(1).data == 5 assert combo.itemData(2).data == 3.5 # Now we change the choices so that there is no matching data - in this case # the index should change to that given by default_index a_prop.set_choices(t, (4, 5, 6)) assert t.a == 5 assert combo.currentIndex() == 1 assert combo.count() == 3 assert combo.itemText(0) == "value: 4" assert combo.itemText(1) == "value: 5" assert combo.itemText(2) == "value: 6" assert combo.itemData(0).data == 4 assert combo.itemData(1).data == 5 assert combo.itemData(2).data == 6 # Finally, if there are too few choices for the default_index to be valid, # pick the last item in the combo a_prop.set_choices(t, (9,)) assert t.a == 9 assert combo.currentIndex() == 0 assert combo.count() == 1 assert combo.itemText(0) == "value: 9" assert combo.itemData(0).data == 9 # Now just make sure that ChoiceSeparator works separator = ChoiceSeparator("header") a_prop.set_choices(t, (separator, 1, 2)) assert combo.count() == 3 assert combo.itemText(0) == "header" assert combo.itemData(0).data is separator # And setting choices to an empty iterable shouldn't cause issues a_prop.set_choices(t, ()) assert combo.count() == 0 # Try including an array in the choices a_prop.set_choices(t, (4, 5, np.array([1, 2, 3]))) def test_connect_combo_selection_invalid(): t = Example() combo = QtWidgets.QComboBox() with pytest.raises(TypeError) as exc: connect_combo_selection(t, "b", combo) assert exc.value.args[0] == "connect_combo_selection requires a SelectionCallbackProperty" def test_connect_combo_selection_via_alias(): """Test that connecting via a CallbackPropertyAlias works correctly.""" t = Example() a_prop = getattr(type(t), "a") a_prop.set_choices(t, [4, 3.5]) a_prop.set_display_func(t, lambda x: f"value: {x}") combo = QtWidgets.QComboBox() # Connect via the alias - this should work because the alias # points to a SelectionCallbackProperty c1 = connect_combo_selection(t, "a_alias", combo) # noqa assert combo.itemText(0) == "value: 4" assert combo.itemText(1) == "value: 3.5" assert combo.itemData(0).data == 4 assert combo.itemData(1).data == 3.5 # Changing combo should update the underlying property via alias combo.setCurrentIndex(1) assert t.a == 3.5 assert t.a_alias == 3.5 combo.setCurrentIndex(0) assert t.a == 4 assert t.a_alias == 4 # Changing the underlying property should update combo t.a = 3.5 assert combo.currentIndex() == 1 # Changing via alias should also update combo t.a_alias = 4 assert combo.currentIndex() == 0 ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/qt/tests/test_connect_list_selection.py0000644000175100017510000001340215252526320023032 0ustar00runnerrunnerimport numpy as np import pytest from echo.alias import CallbackPropertyAlias from echo.core import CallbackProperty from echo.qt.tests.helpers import SKIP_QT_TEST from echo.selection import ChoiceSeparator, SelectionCallbackProperty if SKIP_QT_TEST: pytest.skip(allow_module_level=True) from qtpy import QtWidgets from qtpy.QtCore import Qt from echo.qt.connect import connect_list_selection class Example: a = SelectionCallbackProperty(default_index=1) a_alias = CallbackPropertyAlias("a") b = CallbackProperty() def test_connect_list_selection(): t = Example() a_prop = getattr(type(t), "a") a_prop.set_choices(t, [4, 3.5]) a_prop.set_display_func(t, lambda x: f"value: {x}") list_widget = QtWidgets.QListWidget() c1 = connect_list_selection(t, "a", list_widget) # noqa assert list_widget.item(0).text() == "value: 4" assert list_widget.item(1).text() == "value: 3.5" assert list_widget.item(0).data(Qt.UserRole).data == 4 assert list_widget.item(1).data(Qt.UserRole).data == 3.5 list_widget.setCurrentItem(list_widget.item(1)) assert t.a == 3.5 list_widget.setCurrentItem(list_widget.item(0)) assert t.a == 4 list_widget.setCurrentItem(list_widget.item(-1)) assert t.a is None t.a = 3.5 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(1) t.a = 4 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(0) with pytest.raises(ValueError) as exc: t.a = 2 assert exc.value.args[0] == "value 2 is not in valid choices: [4, 3.5]" t.a = None assert len(list_widget.selectedItems()) == 0 # Changing choices should change Qt list_widget box. Let's first try with a case # in which there is a matching data value in the new list_widget box t.a = 3.5 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(1) a_prop.set_choices(t, (4, 5, 3.5)) assert list_widget.count() == 3 assert t.a == 3.5 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(2) assert list_widget.item(0).text() == "value: 4" assert list_widget.item(1).text() == "value: 5" assert list_widget.item(2).text() == "value: 3.5" assert list_widget.item(0).data(Qt.UserRole).data == 4 assert list_widget.item(1).data(Qt.UserRole).data == 5 assert list_widget.item(2).data(Qt.UserRole).data == 3.5 # Now we change the choices so that there is no matching data - in this case # the index should change to that given by default_index a_prop.set_choices(t, (4, 5, 6)) assert t.a == 5 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(1) assert list_widget.count() == 3 assert list_widget.item(0).text() == "value: 4" assert list_widget.item(1).text() == "value: 5" assert list_widget.item(2).text() == "value: 6" assert list_widget.item(0).data(Qt.UserRole).data == 4 assert list_widget.item(1).data(Qt.UserRole).data == 5 assert list_widget.item(2).data(Qt.UserRole).data == 6 # Finally, if there are too few choices for the default_index to be valid, # pick the last item in the list_widget a_prop.set_choices(t, (9,)) assert t.a == 9 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(0) assert list_widget.count() == 1 assert list_widget.item(0).text() == "value: 9" assert list_widget.item(0).data(Qt.UserRole).data == 9 # Now just make sure that ChoiceSeparator works separator = ChoiceSeparator("header") a_prop.set_choices(t, (separator, 1, 2)) assert list_widget.count() == 3 assert list_widget.item(0).text() == "header" assert list_widget.item(0).data(Qt.UserRole).data is separator # And setting choices to an empty iterable shouldn't cause issues a_prop.set_choices(t, ()) assert list_widget.count() == 0 # Try including an array in the choices a_prop.set_choices(t, (4, 5, np.array([1, 2, 3]))) def test_connect_list_widget_selection_invalid(): t = Example() list_widget = QtWidgets.QListWidget() with pytest.raises(TypeError) as exc: connect_list_selection(t, "b", list_widget) assert exc.value.args[0] == "connect_list_selection requires a SelectionCallbackProperty" def test_connect_list_selection_via_alias(): """Test that connecting via a CallbackPropertyAlias works correctly.""" t = Example() a_prop = getattr(type(t), "a") a_prop.set_choices(t, [4, 3.5]) a_prop.set_display_func(t, lambda x: f"value: {x}") list_widget = QtWidgets.QListWidget() # Connect via the alias - this should work because the alias # points to a SelectionCallbackProperty c1 = connect_list_selection(t, "a_alias", list_widget) # noqa assert list_widget.item(0).text() == "value: 4" assert list_widget.item(1).text() == "value: 3.5" assert list_widget.item(0).data(Qt.UserRole).data == 4 assert list_widget.item(1).data(Qt.UserRole).data == 3.5 # Changing list should update the underlying property via alias list_widget.setCurrentItem(list_widget.item(1)) assert t.a == 3.5 assert t.a_alias == 3.5 list_widget.setCurrentItem(list_widget.item(0)) assert t.a == 4 assert t.a_alias == 4 # Changing the underlying property should update list t.a = 3.5 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(1) # Changing via alias should also update list t.a_alias = 4 assert len(list_widget.selectedItems()) == 1 assert list_widget.selectedItems()[0] is list_widget.item(0) ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/selection.py0000644000175100017510000001076415252526320015451 0ustar00runnerrunnerimport random from weakref import WeakKeyDictionary import numpy as np from .core import CallbackProperty __all__ = ["ChoiceSeparator", "SelectionCallbackProperty"] class ChoiceSeparator: """A label used to indicate a separator/header in a list of choices.""" def __init__(self, label): self._label = label def __str__(self): return self._label def __repr__(self): return f"{self.__class__.__name__}({self._label!r})" class SelectionCallbackProperty(CallbackProperty): def __init__(self, default_index=0, choices=None, display_func=None, comparison_type=None, **kwargs): if choices is not None and "default" not in kwargs: kwargs["default"] = choices[default_index] super().__init__(**kwargs) self.default_index = default_index self.default_choices = choices or [] self.comparison_type = comparison_type self._default_display_func = display_func self._choices = WeakKeyDictionary() self._display = WeakKeyDictionary() self._force_next_sync = WeakKeyDictionary() def __set__(self, instance, value): if value is not None: choices = self.get_choices(instance) # For built-in scalar types we use ==, and for other types we use # is, otherwise e.g. ComponentID returns something that evaluates # to true when using ==. if self.comparison_type == "equality" or (self.comparison_type is None and np.isscalar(value)): if not any(value == x for x in choices): raise ValueError(f"value {value} is not in valid choices: {choices}") if self.comparison_type == "identity" or (self.comparison_type is None and not np.isscalar(value)): if not any(value is x for x in choices): raise ValueError(f"value {value} is not in valid choices: {choices}") super().__set__(instance, value) def force_next_sync(self, instance): self._force_next_sync[instance] = True def _get_full_info(self, instance): if self._force_next_sync.get(instance, False): try: return self.__get__(instance), random.random() finally: self._force_next_sync[instance] = False else: return self.__get__(instance), self.get_choices(instance), self.get_choice_labels(instance) def get_display_func(self, instance): return self._display.get(instance, self._default_display_func) def set_display_func(self, instance, display): self._display[instance] = display # selection = self.__get__(instance) # self.notify(instance, selection, selection) def get_choices(self, instance): return self._choices.get(instance, self.default_choices) def get_choice_labels(self, instance): display = self._display.get(instance, str) labels = [] for choice in self.get_choices(instance): if isinstance(choice, ChoiceSeparator): labels.append(str(choice)) else: labels.append(display(choice)) return labels def set_choices(self, instance, choices): self._choices[instance] = choices self._choices_updated(instance, choices) selection = self.__get__(instance) self.notify(instance, selection, selection) def _choices_updated(self, instance, choices): if not choices: self.__set__(instance, None) return selection = self.__get__(instance) # We do the following because 'selection in choice' actually compares # equality not identity (and we really just care about identity here) # However, for simple Python types, we also need to check ==. for choice in choices: if selection is choice or ( np.isscalar(choice) and (np.isreal(choice) or isinstance(choice, str)) and selection == choice ): return choices_without_separators = [choice for choice in choices if not isinstance(choice, ChoiceSeparator)] if choices_without_separators: try: selection = choices_without_separators[self.default_index] except IndexError: if self.default_index > 0: selection = choices_without_separators[-1] else: selection = choices_without_separators[0] else: selection = None self.__set__(instance, selection) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.6175532 echo-0.16.0/echo/tests/0000755000175100017510000000000015252526356014255 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570255.74251 echo-0.16.0/echo/tests/__init__.py0000644000175100017510000000000015252526320016343 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7428977 echo-0.16.0/echo/tests/test_alias.py0000644000175100017510000002610515252526320016752 0ustar00runnerrunnerimport warnings from unittest.mock import MagicMock import pytest from echo import ( CallbackProperty, CallbackPropertyAlias, HasCallbackProperties, add_callback, delay_callback, ignore_callback, remove_callback, ) from echo.selection import SelectionCallbackProperty class SimpleClass: """Simple class with a callback property and a silent alias.""" color = CallbackProperty("red") colour = CallbackPropertyAlias("color") class SimpleClassDeprecated: """Simple class with a deprecated alias (emits warning).""" color = CallbackProperty("red") colour = CallbackPropertyAlias("color", deprecated=True) class SimpleClassCustomWarning: """Simple class with a custom deprecation warning.""" color = CallbackProperty("red") colour = CallbackPropertyAlias("color", warning="Use color instead of colour!") class SelectionClass: """Class with a SelectionCallbackProperty and an alias.""" color = SelectionCallbackProperty(default="red", choices=["red", "green", "blue"]) colour = CallbackPropertyAlias("color") class HasCallbackPropertiesClass(HasCallbackProperties): """Class using HasCallbackProperties mixin with an alias.""" color = CallbackProperty("red") colour = CallbackPropertyAlias("color") size = CallbackProperty(10) # Basic get/set tests def test_alias_get(): """Test that getting via alias returns the target property value.""" obj = SimpleClass() assert obj.colour == "red" def test_alias_set(): """Test that setting via alias sets the target property value.""" obj = SimpleClass() obj.colour = "blue" assert obj.color == "blue" def test_alias_get_set_roundtrip(): """Test get/set roundtrip through alias.""" obj = SimpleClass() obj.colour = "green" assert obj.colour == "green" assert obj.color == "green" # Deprecation warning tests def test_alias_no_deprecation_warning_by_default(): """Test that no warning is emitted by default (deprecated=False).""" obj = SimpleClass() with warnings.catch_warnings(): warnings.simplefilter("error") # Should not raise any warnings obj.colour = "blue" _ = obj.colour def test_alias_emits_deprecation_warning_on_get(): """Test that deprecation warning is emitted on get when deprecated=True.""" obj = SimpleClassDeprecated() with pytest.warns(DeprecationWarning, match="'colour' is deprecated, use 'color' instead"): _ = obj.colour def test_alias_emits_deprecation_warning_on_set(): """Test that deprecation warning is emitted on set when deprecated=True.""" obj = SimpleClassDeprecated() with pytest.warns(DeprecationWarning, match="'colour' is deprecated, use 'color' instead"): obj.colour = "blue" def test_alias_custom_warning_message(): """Test that custom warning message is used.""" obj = SimpleClassCustomWarning() with pytest.warns(DeprecationWarning, match="Use color instead of colour!"): _ = obj.colour # Callback tests def test_add_callback_via_alias(): """Test that callbacks added via alias are attached to target property.""" obj = SimpleClass() callback = MagicMock() add_callback(obj, "colour", callback) # Changing via the real property should trigger callback obj.color = "blue" callback.assert_called_once_with("blue") def test_add_callback_via_deprecated_alias(): """Test that callbacks added via deprecated alias emit warning.""" obj = SimpleClassDeprecated() callback = MagicMock() with pytest.warns(DeprecationWarning): add_callback(obj, "colour", callback) obj.color = "blue" callback.assert_called_once_with("blue") def test_remove_callback_via_alias(): """Test that callbacks can be removed via alias.""" obj = SimpleClass() callback = MagicMock() add_callback(obj, "color", callback) remove_callback(obj, "colour", callback) obj.color = "blue" assert callback.call_count == 0 def test_callback_triggered_when_setting_via_alias(): """Test that callbacks trigger when value is set via alias.""" obj = SimpleClass() callback = MagicMock() add_callback(obj, "color", callback) obj.colour = "blue" callback.assert_called_once_with("blue") def test_delay_callback_via_alias(): """Test delay_callback works with alias property name.""" obj = SimpleClass() callback = MagicMock() add_callback(obj, "color", callback) with delay_callback(obj, "colour"): obj.color = "blue" obj.color = "green" assert callback.call_count == 0 callback.assert_called_once_with("green") def test_ignore_callback_via_alias(): """Test ignore_callback works with alias property name.""" obj = SimpleClass() callback = MagicMock() add_callback(obj, "color", callback) with ignore_callback(obj, "colour"): obj.color = "blue" assert callback.call_count == 0 assert callback.call_count == 0 # SelectionCallbackProperty tests def test_selection_alias_get_set(): """Test that alias works with SelectionCallbackProperty.""" obj = SelectionClass() assert obj.colour == "red" obj.colour = "blue" assert obj.color == "blue" def test_selection_alias_validation(): """Test that validation still works when setting via alias.""" obj = SelectionClass() with pytest.raises(ValueError, match="not in valid choices"): obj.colour = "yellow" def test_selection_alias_get_choices(): """Test that get_choices can be accessed via alias.""" obj = SelectionClass() # Access choices via class attribute proxy assert SelectionClass.colour.get_choices(obj) == ["red", "green", "blue"] def test_selection_alias_set_choices(): """Test that set_choices can be accessed via alias.""" obj = SelectionClass() # Set choices via class attribute proxy SelectionClass.colour.set_choices(obj, ["cyan", "magenta", "yellow"]) assert obj.color == "cyan" assert SelectionClass.color.get_choices(obj) == ["cyan", "magenta", "yellow"] def test_selection_alias_default_choices(): """Test that default_choices attribute is proxied.""" assert SelectionClass.colour.default_choices == ["red", "green", "blue"] # HasCallbackProperties tests def test_has_callback_properties_is_callback_property(): """Test that is_callback_property returns True for properties and aliases.""" obj = HasCallbackPropertiesClass() assert obj.is_callback_property("color") assert obj.is_callback_property("colour") # Alias also returns True assert obj.is_callback_property("size") def test_has_callback_properties_is_alias(): """Test that is_alias returns True only for aliases.""" obj = HasCallbackPropertiesClass() assert not obj.is_alias("color") assert obj.is_alias("colour") assert not obj.is_alias("size") def test_has_callback_properties_iter_excludes_aliases(): """Test that iter_callback_properties does not include aliases.""" obj = HasCallbackPropertiesClass() prop_names = [name for name, _ in obj.iter_callback_properties()] assert "color" in prop_names assert "size" in prop_names assert "colour" not in prop_names def test_has_callback_properties_callback_properties_excludes_aliases(): """Test that callback_properties() does not include aliases.""" obj = HasCallbackPropertiesClass() prop_names = obj.callback_properties() assert "color" in prop_names assert "size" in prop_names assert "colour" not in prop_names def test_has_callback_properties_add_callback_via_alias(): """Test add_callback method on class with alias.""" obj = HasCallbackPropertiesClass() callback = MagicMock() obj.add_callback("colour", callback) obj.color = "blue" callback.assert_called_once_with("blue") def test_has_callback_properties_remove_callback_via_alias(): """Test remove_callback method on class with alias.""" obj = HasCallbackPropertiesClass() callback = MagicMock() obj.add_callback("color", callback) obj.remove_callback("colour", callback) obj.color = "blue" assert callback.call_count == 0 def test_has_callback_properties_global_callback_via_alias(): """Test that global callbacks receive both property and alias names.""" obj = HasCallbackPropertiesClass() callback = MagicMock() obj.add_global_callback(callback) obj.colour = "blue" # Should receive both the target property name and the alias name callback.assert_called_once_with(color="blue", colour="blue") def test_has_callback_properties_global_callback_via_property(): """Test that global callbacks receive alias names when setting via property.""" obj = HasCallbackPropertiesClass() callback = MagicMock() obj.add_global_callback(callback) obj.color = "blue" # Should receive both the target property name and the alias name callback.assert_called_once_with(color="blue", colour="blue") # Class attribute access tests def test_class_attribute_access_returns_alias(): """Test that accessing alias on class returns the alias object.""" assert isinstance(SimpleClass.colour, CallbackPropertyAlias) def test_alias_target_property(): """Test _target_property returns the correct CallbackProperty.""" assert SimpleClass.colour._target_property is SimpleClass.color def test_alias_getattr_proxy(): """Test that __getattr__ proxies public attributes to target property.""" # Test proxy by accessing methods on the target property # For SelectionCallbackProperty, get_choices is a method we can access obj = SelectionClass() # Accessing via alias should proxy to the target property's method choices = SelectionClass.colour.get_choices(obj) assert choices == ["red", "green", "blue"] # Edge case tests def test_multiple_instances_independent(): """Test that alias works correctly with multiple instances.""" obj1 = SimpleClass() obj2 = SimpleClass() obj1.colour = "blue" assert obj1.color == "blue" assert obj2.color == "red" def test_alias_with_echo_old(): """Test callback with echo_old via alias.""" obj = SimpleClass() callback = MagicMock() obj.color = "initial" add_callback(obj, "colour", callback, echo_old=True) obj.color = "new" callback.assert_called_once_with("initial", "new") def test_alias_with_validator(): """Test validator callback via alias.""" obj = SimpleClass() def uppercase_validator(value): return value.upper() add_callback(obj, "colour", uppercase_validator, validator=True) obj.color = "blue" assert obj.color == "BLUE" def test_alias_private_attribute_access_raises(): """Test that accessing private attributes via alias raises AttributeError.""" with pytest.raises(AttributeError): _ = SimpleClass.colour._private_attr def test_alias_before_class_defined(): """Test that accessing attributes on alias before class is defined raises.""" alias = CallbackPropertyAlias("target") # _owner is None before __set_name__ is called with pytest.raises(AttributeError, match="Cannot access .* before class is fully defined"): _ = alias.some_attribute ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7428977 echo-0.16.0/echo/tests/test_callback_container.py0000644000175100017510000000533215252526320021456 0ustar00runnerrunnerimport pytest from ..callback_container import CallbackContainer def func_a(): return 1 def func_b(): return 2 class SimpleClass: def __init__(self, value): self._value = value def meth(self): return self._value def test_callback_container_func(): container = CallbackContainer() container.append(func_a) assert len(container) == 1 assert func_a in container assert func_b not in container container.append(func_b) assert len(container) == 2 assert func_a in container assert func_b in container callbacks = list(container) assert callbacks[0] is func_a assert callbacks[1] is func_b container.clear() assert len(container) == 0 assert func_a not in container assert func_b not in container def test_callback_container_meth(): instance1 = SimpleClass(2) instance2 = SimpleClass(3) container = CallbackContainer() container.append(instance1.meth) assert len(container) == 1 assert instance1.meth in container assert instance2.meth not in container container.append(instance2.meth) assert len(container) == 2 assert instance1.meth in container assert instance2.meth in container callbacks = list(container) assert callbacks[0]() == 2 assert callbacks[1]() == 3 container.clear() assert len(container) == 0 assert instance1.meth not in container assert instance2.meth not in container def test_callback_container_invalid(): container = CallbackContainer() with pytest.raises(TypeError, match=r"Only callable values can be stored in CallbackContainer"): container.append("banana") def test_callback_container_duplicates(): # Don't store duplicates instance1 = SimpleClass(2) instance2 = SimpleClass(3) container = CallbackContainer() container.append(func_a) container.append(instance1.meth) container.append(instance2.meth) container.append(instance1.meth) container.append(instance2.meth, priority=2) # will be added as different priority container.append(func_a) container.append(instance2.meth) assert len(container) == 4 assert func_a in container assert func_b not in container assert instance1.meth in container assert instance2.meth in container def test_callback_container_remove(): instance = SimpleClass(2) container = CallbackContainer() container.append(func_a) container.append(instance.meth) container.append(instance.meth, priority=2) assert len(container) == 3 container.remove(instance.meth) assert len(container) == 1 container.remove(func_a) assert len(container) == 0 container.remove(func_b) assert len(container) == 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7428977 echo-0.16.0/echo/tests/test_containers.py0000644000175100017510000003556515252526320020040 0ustar00runnerrunnerfrom unittest.mock import MagicMock import pytest from echo import CallbackProperty, DictCallbackProperty, HasCallbackProperties, ListCallbackProperty class StubList(HasCallbackProperties): prop1 = ListCallbackProperty() prop2 = ListCallbackProperty() class StubDict(HasCallbackProperties): prop1 = DictCallbackProperty() prop2 = DictCallbackProperty() class Simple(HasCallbackProperties): a = CallbackProperty() def test_list_normal_callback(): stub = StubList() test1 = MagicMock() stub.add_callback("prop1", test1) stub.prop1 = [3] assert test1.call_count == 1 stub.prop2 = [4] assert test1.call_count == 1 def test_list_invalid(): stub = StubList() with pytest.raises(TypeError) as exc: stub.prop1 = "banana" assert exc.value.args[0] == "callback property should be a list" def test_list_default_empty(): stub = StubList() stub.prop1.append(1) def test_list_explicit_default(): class TestList(HasCallbackProperties): prop = ListCallbackProperty(default=[1, 2, 3]) test1 = TestList() assert test1.prop == [1, 2, 3] test2 = TestList() assert test2.prop == [1, 2, 3] test1.prop.append(4) assert test1.prop == [1, 2, 3, 4] assert test2.prop == [1, 2, 3] def test_list_change_callback(): stub = StubList() test1 = MagicMock() stub.add_callback("prop1", test1) assert test1.call_count == 0 stub.prop1.append(3) assert test1.call_count == 1 assert stub.prop1 == [3] stub.prop1.clear() assert test1.call_count == 2 assert stub.prop1 == [] stub.prop1.extend([1, 2, 3]) assert test1.call_count == 3 assert stub.prop1 == [1, 2, 3] stub.prop1.insert(0, -1) assert test1.call_count == 4 assert stub.prop1 == [-1, 1, 2, 3] p = stub.prop1.pop() assert test1.call_count == 5 assert p == 3 assert stub.prop1 == [-1, 1, 2] stub.prop1.remove(1) assert test1.call_count == 6 assert stub.prop1 == [-1, 2] stub.prop1.reverse() assert test1.call_count == 7 assert stub.prop1 == [2, -1] stub.prop1.sort() assert test1.call_count == 8 assert stub.prop1 == [-1, 2] stub.prop1[0] = 3 assert test1.call_count == 9 assert stub.prop1 == [3, 2] stub.prop1[:] = [] assert test1.call_count == 10 assert stub.prop1 == [] def test_state_in_a_list(): stub = StubList() state1 = Simple() state2 = Simple() state3 = Simple() state4 = Simple() state5 = Simple() # Add three of the state objects to the list in different ways stub.prop1.append(state1) stub.prop1.extend([state2, 3.4]) stub.prop1.insert(1, state3) stub.prop1[3] = state4 # Check that all states except state 5 have a callback added assert len(state1._global_callbacks) == 1 assert len(state2._global_callbacks) == 1 assert len(state3._global_callbacks) == 1 assert len(state4._global_callbacks) == 1 assert len(state5._global_callbacks) == 0 # Add a callback to the main list callback = MagicMock() stub.add_callback("prop1", callback) # Check that modifying attributes of the state objects triggers the list # callback. assert callback.call_count == 0 state1.a = 1 assert callback.call_count == 1 state2.a = 1 assert callback.call_count == 2 state3.a = 1 assert callback.call_count == 3 state4.a = 1 assert callback.call_count == 4 state5.a = 1 assert callback.call_count == 4 # Remove one of the state objects and try again stub.prop1.pop(0) assert callback.call_count == 5 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 1 assert len(state3._global_callbacks) == 1 assert len(state4._global_callbacks) == 1 assert len(state5._global_callbacks) == 0 # Now modifying state1 sholdn't affect the call cont state1.a = 2 assert callback.call_count == 5 state2.a = 2 assert callback.call_count == 6 state3.a = 2 assert callback.call_count == 7 state4.a = 2 assert callback.call_count == 8 state5.a = 2 assert callback.call_count == 8 # Remove again this time using remove stub.prop1.remove(state2) assert callback.call_count == 9 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 1 assert len(state4._global_callbacks) == 1 assert len(state5._global_callbacks) == 0 # Now modifying state2 sholdn't affect the call cont state1.a = 3 assert callback.call_count == 9 state2.a = 3 assert callback.call_count == 9 state3.a = 3 assert callback.call_count == 10 state4.a = 3 assert callback.call_count == 11 state5.a = 3 assert callback.call_count == 11 # Remove using item access stub.prop1[1] = 3.3 assert callback.call_count == 12 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 1 assert len(state4._global_callbacks) == 0 assert len(state5._global_callbacks) == 0 # Now modifying state4 sholdn't affect the call cont state1.a = 4 assert callback.call_count == 12 state2.a = 4 assert callback.call_count == 12 state3.a = 4 assert callback.call_count == 13 state4.a = 4 assert callback.call_count == 13 state5.a = 4 assert callback.call_count == 13 # Now use slice access to remove state3 and add state5 in one go stub.prop1[0:2] = [2.2, state5] assert callback.call_count == 14 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 0 assert len(state4._global_callbacks) == 0 assert len(state5._global_callbacks) == 1 # Now only modifying state5 should have an effect state1.a = 5 assert callback.call_count == 14 state2.a = 5 assert callback.call_count == 14 state3.a = 5 assert callback.call_count == 14 state4.a = 5 assert callback.call_count == 14 state5.a = 5 assert callback.call_count == 15 # On Python 3, check that clear does the right thing stub.prop1.clear() assert callback.call_count == 16 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 0 assert len(state4._global_callbacks) == 0 assert len(state5._global_callbacks) == 0 # Now the callback should never be called state1.a = 6 assert callback.call_count == 16 state2.a = 6 assert callback.call_count == 16 state3.a = 6 assert callback.call_count == 16 state4.a = 6 assert callback.call_count == 16 state5.a = 6 assert callback.call_count == 16 def test_list_nested_callbacks(): stub1 = StubList() stub2 = StubList() simple = Simple() stub1.prop1.append(stub2) stub1.prop1.append(simple) test1 = MagicMock() stub1.add_callback("prop1", test1) stub2.prop1.append(2) assert test1.call_count == 1 simple.a = "banana!" assert test1.call_count == 2 def test_dict_normal_callback(): stub = StubDict() test1 = MagicMock() stub.add_callback("prop1", test1) stub.prop1 = {"a": 1} assert test1.call_count == 1 stub.prop2 = {"b": 2} assert test1.call_count == 1 def test_dict_invalid(): stub = StubDict() with pytest.raises(TypeError) as exc: stub.prop1 = "banana" assert exc.value.args[0] == "Callback property should be a dictionary." def test_dict_default_empty(): stub = StubDict() stub.prop1["a"] = 1 def test_dict_explicit_default(): class TestDict(HasCallbackProperties): prop = DictCallbackProperty(default={"a": 1, "b": 2}) test1 = TestDict() assert test1.prop == {"a": 1, "b": 2} test2 = TestDict() assert test2.prop == {"a": 1, "b": 2} test1.prop["c"] = 3 assert test1.prop == {"a": 1, "b": 2, "c": 3} assert test2.prop == {"a": 1, "b": 2} def test_dict_change_callback(): stub = StubDict() test1 = MagicMock() stub.add_callback("prop1", test1) assert test1.call_count == 0 stub.prop1["a"] = 1 assert test1.call_count == 1 assert stub.prop1 == {"a": 1} stub.prop1.clear() assert test1.call_count == 2 assert stub.prop1 == {} stub.prop1.update({"b": 2, "c": 3, "d": 4}) assert test1.call_count == 3 assert stub.prop1 == {"b": 2, "c": 3, "d": 4} p = stub.prop1.pop("b") assert test1.call_count == 4 assert p == 2 assert stub.prop1 == {"c": 3, "d": 4} k, v = stub.prop1.popitem() assert test1.call_count == 5 assert stub.prop1 == {"c": 3} if k == "d" else {"d": 4} remaining_key = "c" if k == "d" else "d" stub.prop1[remaining_key] = 6 assert test1.call_count == 6 assert stub.prop1 == {remaining_key: 6} stub.prop1.clear() assert test1.call_count == 7 assert stub.prop1 == {} def test_state_in_a_dict(): stub = StubDict() state1 = Simple() state2 = Simple() state3 = Simple() # Add three of the state objects to the dict in different ways stub.prop1["a"] = state1 stub.prop1.update({"b": state2}) # Check that all states except state 3 have a callback added assert len(state1._global_callbacks) == 1 assert len(state2._global_callbacks) == 1 assert len(state3._global_callbacks) == 0 # Add a callback to the main dict callback = MagicMock() stub.add_callback("prop1", callback) # Check that modifying attributes of the state objects triggers the list # callback. assert callback.call_count == 0 state1.a = 1 assert callback.call_count == 1 state2.a = 1 assert callback.call_count == 2 state3.a = 1 assert callback.call_count == 2 # Remove one of the state objects and try again stub.prop1.pop("a") assert callback.call_count == 3 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 1 assert len(state3._global_callbacks) == 0 # Now modifying state1 sholdn't affect the call count state1.a = 2 assert callback.call_count == 3 state2.a = 2 assert callback.call_count == 4 state3.a = 2 assert callback.call_count == 4 # Remove using item access and add state3 stub.prop1["b"] = 3.3 stub.prop1["c"] = state3 assert callback.call_count == 6 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 1 # Now modifying state2 sholdn't affect the call cont state1.a = 4 assert callback.call_count == 6 state2.a = 4 assert callback.call_count == 6 state3.a = 4 assert callback.call_count == 7 # Check that clear does the right thing stub.prop1.clear() assert callback.call_count == 8 assert len(state1._global_callbacks) == 0 assert len(state2._global_callbacks) == 0 assert len(state3._global_callbacks) == 0 # Now the callback should never be called state1.a = 6 assert callback.call_count == 8 state2.a = 6 assert callback.call_count == 8 state3.a = 6 assert callback.call_count == 8 def test_dict_nested_callbacks(): stub1 = StubDict() stub2 = StubDict() simple = Simple() stub1.prop1["a"] = stub2 stub1.prop1["b"] = simple test1 = MagicMock() stub1.add_callback("prop1", test1) stub2.prop1["c"] = 2 assert test1.call_count == 1 simple.a = "banana!" assert test1.call_count == 2 def test_multiple_nested_list_and_dict(): stubd = StubDict() test1 = MagicMock() stubd.add_callback("prop1", test1) stubd.prop1 = {"a": {"b": {"c": 1}}} assert test1.call_count == 1 stubd.prop1["a"]["b"]["c"] = 2 assert test1.call_count == 2 stubd.prop1["a"]["b"]["c"] = {"d": 3} assert test1.call_count == 3 stubd.prop1["a"]["b"]["c"]["d"] = 4 assert test1.call_count == 4 stubd.prop1["a"]["b"] = [1, 2, 3] assert test1.call_count == 5 stubd.prop1["a"]["b"][1] = {"e": 6} assert test1.call_count == 6 stubd.prop1["a"]["b"][1]["e"] = 7 assert test1.call_count == 7 stubd.prop1["a"] = 2 assert test1.call_count == 8 stubl = StubList() test2 = MagicMock() stubl.add_callback("prop1", test2) stubl.prop1 = [1, 2, {"a": 3, "b": [4, 5, {"c": 6}]}] assert test2.call_count == 1 stubl.prop1[2]["b"][2]["c"] = 5 assert test2.call_count == 2 stubl.prop1[2]["b"][2] = {"d": 7} assert test2.call_count == 3 stubl.prop1[2]["b"][2]["d"] = 2 assert test2.call_count == 4 stubl.prop1[2]["b"][2] = [1, 2] assert test2.call_count == 5 stubl.prop1[2]["b"][2][0] = 3 assert test2.call_count == 6 stubl.prop1[2] = [1, 2] assert test2.call_count == 7 stubl.prop1[2][1] = 3 assert test2.call_count == 8 def test_list_additional_callbacks(): stub = StubList() test1 = MagicMock() test2 = MagicMock() stub.add_callback("prop1", test1) stub.prop1 = [3] stub.prop2 = [4] assert test1.call_count == 1 assert test2.call_count == 0 stub.prop1.add_callback(test2) stub.prop2.append(5) assert test1.call_count == 1 assert test2.call_count == 0 stub.prop1.append(2) assert test1.call_count == 2 assert test2.call_count == 1 stub.prop1.remove_callback(test2) stub.prop1.append(4) assert test1.call_count == 3 assert test2.call_count == 1 def test_dict_additional_callbacks(): stub = StubDict() test1 = MagicMock() test2 = MagicMock() stub.add_callback("prop1", test1) stub.prop1 = {"a": 1} stub.prop2 = {"b": 2} assert test1.call_count == 1 assert test2.call_count == 0 stub.prop1.add_callback(test2) stub.prop2["c"] = 3 assert test1.call_count == 1 assert test2.call_count == 0 stub.prop1["d"] = 4 assert test1.call_count == 2 assert test2.call_count == 1 stub.prop1.remove_callback(test2) stub.prop1["e"] = 5 assert test1.call_count == 3 assert test2.call_count == 1 def test_item_validator(): stub_list = StubList() stub_dict = StubDict() def add_one(value): return value + 1 stub_list.prop1.add_callback(add_one, validator=True) stub_list.prop1.append(1) assert stub_list.prop1 == [2] stub_dict.prop1.add_callback(add_one, validator=True) stub_dict.prop1["a"] = 2 assert stub_dict.prop1 == {"a": 3} def test_list_multiple(): # Regression test for a bug that caused callbacks to happen for a container # class property if one of the properties of a nested state was set to the # same value it was already set to. stub_list = StubList() stub = Simple() stub_list.prop1.append(stub) test1 = MagicMock() stub_list.add_callback("prop1", test1) test2 = MagicMock() stub.add_callback("a", test2) stub.a = False stub.a = True stub.a = True assert test1.call_count == 2 assert test2.call_count == 2 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7431731 echo-0.16.0/echo/tests/test_core.py0000644000175100017510000004066215252526320016615 0ustar00runnerrunnerfrom unittest.mock import MagicMock, call import pytest from echo import ( CallbackProperty, HasCallbackProperties, add_callback, callback_property, delay_callback, ignore_callback, keep_in_sync, remove_callback, ) class Stub: prop1 = CallbackProperty() prop2 = CallbackProperty(5) prop3 = 5 class DecoratorStub: def __init__(self): self._val = 1 @callback_property def prop(self): return self._val * 2 @prop.setter def prop(self, value): self._val = value def test_attribute_like_access(): stub = Stub() assert stub.prop1 is None assert stub.prop2 == 5 def test_attribute_like_set(): stub = Stub() stub.prop1 = 10 assert stub.prop1 == 10 def test_class_access(): stub = Stub() assert isinstance(type(stub).prop1, CallbackProperty) def test_callback_fire_on_change(): stub = Stub() test = MagicMock() add_callback(stub, "prop1", test) stub.prop1 = 5 test.assert_called_once_with(5) def test_callbacks_only_called_on_value_change(): stub = Stub() test = MagicMock() add_callback(stub, "prop1", test) stub.prop1 = 5 test.assert_called_once_with(5) stub.prop1 = 5 assert test.call_count == 1 def test_callbacks_are_instance_specific(): s1, s2 = Stub(), Stub() test = MagicMock() add_callback(s2, "prop1", test) s1.prop1 = 100 assert test.call_count == 0 def test_remove_callback(): stub = Stub() test = MagicMock() add_callback(stub, "prop1", test) remove_callback(stub, "prop1", test) stub.prop1 = 5 assert test.call_count == 0 def test_add_callback_attribute_error_on_bad_name(): stub = Stub() with pytest.raises(AttributeError): add_callback(stub, "bad_property", None) def test_add_callback_type_error_if_not_calllback(): stub = Stub() with pytest.raises(TypeError) as exc: add_callback(stub, "prop3", None) assert exc.value.args[0] == "prop3 is not a CallbackProperty" def test_remove_callback_attribute_error_on_bad_name(): stub = Stub() with pytest.raises(AttributeError): remove_callback(stub, "bad_property", None) def test_remove_callback_wrong_function(): stub = Stub() test = MagicMock() test2 = MagicMock() add_callback(stub, "prop1", test) with pytest.raises(ValueError) as exc: remove_callback(stub, "prop1", test2) assert exc.value.args[0].startswith("Callback function not found") def test_remove_non_callback_property(): stub = Stub() with pytest.raises(TypeError) as exc: remove_callback(stub, "prop3", None) assert exc.value.args[0] == "prop3 is not a CallbackProperty" def test_remove_callback_not_found(): stub = Stub() with pytest.raises(ValueError) as exc: remove_callback(stub, "prop1", None) assert exc.value.args[0] == "Callback function not found: None" def test_disable_callback(): stub = Stub() test = MagicMock() add_callback(stub, "prop1", test) Stub.prop1.disable(stub) stub.prop1 = 100 assert test.call_count == 0 Stub.prop1.enable(stub) stub.prop1 = 100 assert test.call_count == 0 # not changed stub.prop1 = 200 assert test.call_count == 1 def test_delay_callback(): test = MagicMock() stub = Stub() add_callback(stub, "prop1", test) with delay_callback(stub, "prop1"): stub.prop1 = 100 stub.prop1 = 200 stub.prop1 = 300 assert test.call_count == 0 test.assert_called_once_with(300) def test_delay_callback_nested(): test = MagicMock() stub = Stub() add_callback(stub, "prop1", test) with delay_callback(stub, "prop1"): with delay_callback(stub, "prop1"): stub.prop1 = 100 stub.prop1 = 200 stub.prop1 = 300 assert test.call_count == 0 assert test.call_count == 0 test.assert_called_once_with(300) def test_delay_callback_not_called_if_unmodified(): test = MagicMock() stub = Stub() add_callback(stub, "prop1", test) with delay_callback(stub, "prop1"): pass assert test.call_count == 0 def test_callback_with_two_arguments(): stub = Stub() stub.prop1 = 5 on_change = MagicMock() add_callback(stub, "prop1", on_change, echo_old=True) stub.prop1 = 10 on_change.assert_called_once_with(5, 10) @pytest.mark.parametrize("context_func", (delay_callback, ignore_callback)) def test_context_on_non_callback(context_func): stub = Stub() with pytest.raises(TypeError) as exc: with context_func(stub, "prop3"): pass assert exc.value.args[0] == "prop3 is not a CallbackProperty" def test_delay_multiple(): stub = Stub() test = MagicMock() test2 = MagicMock() add_callback(stub, "prop1", test) add_callback(stub, "prop2", test2) with delay_callback(stub, "prop1", "prop2"): stub.prop1 = 50 stub.prop1 = 100 stub.prop2 = 200 assert test.call_count == 0 assert test2.call_count == 0 test.assert_called_once_with(100) test2.assert_called_once_with(200) def test_ignore_multiple(): stub = Stub() test = MagicMock() test2 = MagicMock() add_callback(stub, "prop1", test) add_callback(stub, "prop2", test2) with ignore_callback(stub, "prop1", "prop2"): stub.prop1 = 100 stub.prop2 = 200 assert test.call_count == 0 assert test2.call_count == 0 assert test.call_count == 0 assert test2.call_count == 0 def test_delay_only_calls_if_changed(): stub = Stub() test = MagicMock() add_callback(stub, "prop1", test) with delay_callback(stub, "prop1"): pass assert test.call_count == 0 val = stub.prop1 with delay_callback(stub, "prop1"): stub.prop1 = val assert test.call_count == 0 def test_decorator_form(): stub = DecoratorStub() test = MagicMock() add_callback(stub, "prop", test) assert stub.prop == 2 stub.prop = 5 test.assert_called_once_with(10) assert stub.prop == 10 def test_docstring(): class Simple: a = CallbackProperty(docstring="important") s = Simple() assert type(s).a.__doc__ == "important" class State(HasCallbackProperties): a = CallbackProperty() b = CallbackProperty() c = CallbackProperty() d = 1 def test_class_add_remove_callback(): state = State() class mockclass: def __init__(self): self.call_count = 0 self.args = () self.kwargs = {} def __call__(self, *args, **kwargs): self.args = args self.kwargs = kwargs self.call_count += 1 test1 = mockclass() state.add_callback("a", test1) # Deliberaty adding to c twice to make sure it works fine with two callbacks test2 = mockclass() state.add_callback("c", test2) test3 = mockclass() state.add_callback("c", test3, echo_old=True) test4 = mockclass() state.add_global_callback(test4) state.a = 1 assert test1.call_count == 1 assert test1.args == (1,) assert test2.call_count == 0 assert test3.call_count == 0 assert test4.call_count == 1 assert test4.kwargs == dict(a=1) state.b = 1 assert test1.call_count == 1 assert test2.call_count == 0 assert test3.call_count == 0 assert test4.call_count == 2 assert test4.kwargs == dict(b=1) state.c = 1 assert test1.call_count == 1 assert test2.call_count == 1 assert test4.kwargs == dict(c=1) assert test3.call_count == 1 assert test3.args == (None, 1) assert test4.call_count == 3 assert test4.kwargs == dict(c=1) state.remove_callback("a", test1) state.a = 2 state.b = 2 state.c = 2 assert test1.call_count == 1 assert test2.call_count == 2 assert test3.call_count == 2 assert test4.call_count == 6 state.remove_callback("c", test2) state.a = 3 state.b = 3 state.c = 3 assert test1.call_count == 1 assert test2.call_count == 2 assert test3.call_count == 3 assert test4.call_count == 9 state.remove_callback("c", test3) state.a = 4 state.b = 4 state.c = 4 assert test1.call_count == 1 assert test2.call_count == 2 assert test3.call_count == 3 assert test4.call_count == 12 state.remove_global_callback(test4) state.a = 6 state.b = 6 state.c = 6 assert test1.call_count == 1 assert test2.call_count == 2 assert test3.call_count == 3 assert test4.call_count == 12 def test_class_is_callback_property(): state = State() assert state.is_callback_property("a") assert state.is_callback_property("b") assert state.is_callback_property("c") assert not state.is_callback_property("d") def test_class_add_remove_callback_invalid(): def callback(): pass state = State() state.z = "banana" with pytest.raises(TypeError) as exc: state.add_callback("banana", callback) assert exc.value.args[0] == "attribute 'banana' is not a callback property" with pytest.raises(TypeError) as exc: state.remove_callback("banana", callback) assert exc.value.args[0] == "attribute 'banana' is not a callback property" def test_keep_in_sync(): class State1: a = CallbackProperty() b = CallbackProperty() class State2: c = CallbackProperty() state1 = State1() state2 = State2() state1_control = State1() state2_control = State2() s1 = keep_in_sync(state1, "a", state1, "b") s2 = keep_in_sync(state1, "a", state2, "c") state1.a = 1 assert state1.b == 1 assert state1_control.b is None assert state2.c == 1 assert state2_control.c is None state1.b = 3 assert state1.a == 3 assert state1_control.a is None assert state2.c == 3 assert state2_control.c is None state2.c = 5 assert state1.a == 5 assert state1_control.a is None assert state1.b == 5 assert state1_control.b is None s1.disable_syncing() state1.a = 7 assert state1.b == 5 assert state1_control.b is None assert state2.c == 7 assert state2_control.c is None s2.disable_syncing() def test_cleanup_when_objects_destroyed(): state = State() class BasicClass: def __init__(self, s): self.s = s self.s.add_callback("a", self.callback) self.raise_error = False def callback(self, arg): if self.raise_error: raise ValueError("Should never get here") def isolated(state): c = BasicClass(state) state.a = 1 c.raise_error = True isolated(state) state.a = 2 def test_cleanup_when_objects_destroyed_kwargs(): state = State() class BasicClass: def __init__(self, s): self.s = s self.s.add_global_callback(self.callback) self.raise_error = False def callback(self, **kwargs): if self.raise_error: raise ValueError("Should never get here") def isolated(state): c = BasicClass(state) state.a = 1 c.raise_error = True isolated(state) state.a = 2 def test_delay_global_callback(): # Regression test to make sure that delay_callback works for global # callbacks too. state = State() test1 = MagicMock() state.add_callback("a", test1) test2 = MagicMock() state.add_global_callback(test2) with delay_callback(state, "a"): state.a = 100 assert test1.call_count == 0 assert test2.call_count == 0 test1.assert_called_once_with(100) test2.assert_called_once_with(a=100) test2.reset_mock() with delay_callback(state, "a"): state.b = 200 assert test2.call_count == 1 test2.assert_called_once_with(b=200) test2.reset_mock() with delay_callback(state, "a", "b"): state.a = 300 state.b = 400 assert test2.call_count == 0 test2.assert_called_once_with(a=300, b=400) def test_delay_global_callback_stub(): # Make sure that adding the global callback delay functionality doesn't # break things when we are dealing with a plain class without HasCallbackProperties stub = Stub() test1 = MagicMock() add_callback(stub, "prop1", test1) with delay_callback(stub, "prop1"): stub.prop1 = 100 assert test1.call_count == 0 test1.assert_called_once_with(100) def test_ignore_global_callback(): # Regression test to make sure that ignore_callback works for global # callbacks too. state = State() test1 = MagicMock() state.add_callback("a", test1) test2 = MagicMock() state.add_global_callback(test2) with ignore_callback(state, "a"): state.a = 100 assert test1.call_count == 0 assert test2.call_count == 0 assert test1.call_count == 0 assert test2.call_count == 0 test2.reset_mock() with ignore_callback(state, "a"): state.b = 200 assert test2.call_count == 1 test2.assert_called_once_with(b=200) test2.reset_mock() with ignore_callback(state, "a", "b"): state.a = 300 state.b = 400 assert test2.call_count == 0 assert test2.call_count == 0 def test_ignore_global_callback_stub(): # Make sure that adding the global callback ignore functionality doesn't # break things when we are dealing with a plain class without HasCallbackProperties stub = Stub() test1 = MagicMock() add_callback(stub, "prop1", test1) with ignore_callback(stub, "prop1"): stub.prop1 = 100 assert test1.call_count == 0 assert test1.call_count == 0 def test_delay_in_delayed_callback(): # Regression test for a bug that occurred if a delayed callback included # a delay itself. state = State() def callback(*args, **kwargs): with delay_callback(state, "a"): state.a = 2 state.add_callback("a", callback) with delay_callback(state, "a", "b"): state.a = 100 def test_ignore_in_ignored_callback(): # Regression test for a bug that occurred if a delayed callback included # a delay itself. state = State() def callback(*args, **kwargs): with ignore_callback(state, "a"): state.a = 2 state.add_callback("a", callback) with ignore_callback(state, "a", "b"): state.a = 100 def test_validator(): state = State() state.a = 1 state.b = 2.2 def add_one(new_value): return new_value + 1 def preserve_type(old_value, new_value): if type(new_value) is not type(old_value): raise TypeError("types should not change") state.add_callback("a", add_one, validator=True) state.add_callback("b", preserve_type, validator=True, echo_old=True) state.a = 3 assert state.a == 4 state.b = 3.2 with pytest.raises(TypeError, match="types should not change"): state.b = 2 def test_global_callback_avoid_multiple(): # Regression test for a bug that caused global callbacks to be # called even if the value did not change state = State() test = MagicMock() state.add_global_callback(test) state.a = False state.a = True state.a = True assert test.call_count == 2 with delay_callback(state, "a"): state.a = False state.a = False assert test.call_count == 2 assert test.call_count == 3 def test_undefined_attribute_decorator(): # Regression test for a bug that caused an error when using the # @callback_property decorator and global callbacks class Foo(HasCallbackProperties): @callback_property def x(self): return self._x @x.setter def x(self, value): self._x = value test = MagicMock() foo = Foo() foo.add_global_callback(test) foo.x = 1 assert test.call_count == 1 def test_priority_order_twoargs(): mock = MagicMock() def one_arg(value): mock(value) def two_arg(old, new): mock(old, new) state = State() state.add_callback("a", one_arg, priority=10) state.add_callback("a", two_arg, echo_old=True, priority=1000) state.a = 2 calls = [call(None, 2), call(2)] mock.assert_has_calls(calls, any_order=False) mock.reset_mock() state2 = State() state2.add_callback("a", one_arg, priority=1000) state2.add_callback("a", two_arg, echo_old=True, priority=10) state2.a = 7 calls = [call(7), call(None, 7)] mock.assert_has_calls(calls, any_order=False) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7431731 echo-0.16.0/echo/tests/test_selection.py0000644000175100017510000001044215252526320017643 0ustar00runnerrunnerfrom unittest.mock import MagicMock import pytest from ..core import HasCallbackProperties, delay_callback from ..selection import ChoiceSeparator, SelectionCallbackProperty class Example(HasCallbackProperties): a = SelectionCallbackProperty() b = SelectionCallbackProperty(default_index=1) c = SelectionCallbackProperty(default_index=-1) class TestSelectionCallbackProperty: def setup_method(self, method): self.state = Example() self.a = Example.a self.b = Example.b self.c = Example.c def test_set_choices(self): # make sure that default_index is respected self.a.set_choices(self.state, [1, 2]) self.b.set_choices(self.state, [1, 2]) self.c.set_choices(self.state, [1, 2, 3]) assert self.state.a == 1 assert self.state.b == 2 assert self.state.c == 3 self.a.set_choices(self.state, None) assert self.state.a is None def test_set_value(self): # Since there are no choices set yet, the properties cannot be set to # any values yet. with pytest.raises(ValueError) as exc: self.state.a = 1 assert exc.value.args[0] == "value 1 is not in valid choices: []" self.a.set_choices(self.state, [1, 2]) # The following should work since it is a valid choice self.state.a = 2 # Again if we try and set to an invalid value, things break with pytest.raises(ValueError) as exc: self.state.a = 3 assert exc.value.args[0] == "value 3 is not in valid choices: [1, 2]" def test_value_constant(self): # Make sure that the value is preserved if possible self.a.set_choices(self.state, [1, 2]) self.state.a = 2 self.a.set_choices(self.state, [2, 5, 3]) assert self.state.a == 2 self.a.set_choices(self.state, [1, 4, 5]) assert self.state.a == 1 self.a.set_choices(self.state, [4, 2, 1]) assert self.state.a == 1 def test_get_choices(self): self.a.set_choices(self.state, [1, 2]) self.a.get_choices == [1, 2] self.a.set_choices(self.state, [2, 5, 3]) self.a.get_choices == [2, 5, 3] def test_display_func(self): separator = ChoiceSeparator("header") self.a.set_choices(self.state, [separator, 1, 2]) self.a.get_choice_labels(self.state) == ["header", "1", "2"] assert self.a.get_display_func(self.state) is None assert self.b.get_display_func(self.state) is None def val(x): return f"val{x}" self.a.set_display_func(self.state, val) self.a.get_choice_labels(self.state) == ["header", "val1", "val2"] assert self.a.get_display_func(self.state) is val assert self.b.get_display_func(self.state) is None def test_callbacks(self): # Make sure that callbacks are called when either choices or selection # are changed func = MagicMock() self.state.add_callback("a", func) self.a.set_choices(self.state, [1, 2, 3]) func.assert_called_with(1) self.state.a = 2 func.assert_called_with(2) self.a.set_choices(self.state, [4, 5, 6]) func.assert_called_with(4) def test_choice_separator(self): separator = ChoiceSeparator("header") self.a.set_choices(self.state, [separator, 1, 2]) assert self.state.a == 1 separator = ChoiceSeparator("header") self.a.set_choices(self.state, [separator]) assert self.state.a is None def test_delay(self): func = MagicMock() self.state.add_callback("a", func) # Here we set the choices and as a result the selection changes from # None to a value, so the callback is called after the delay block with delay_callback(self.state, "a"): self.a.set_choices(self.state, [4, 5, 6]) assert func.call_count == 0 func.assert_called_once_with(4) func.reset_mock() # Check that the callback gets called even if only the choices # but not the selection are changed in a delay block with delay_callback(self.state, "a"): self.a.set_choices(self.state, [1, 2, 4]) assert func.call_count == 0 func.assert_called_once_with(4) func.reset_mock() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7431731 echo-0.16.0/echo/version.py0000644000175100017510000000017515252526320015144 0ustar00runnerrunnerimport importlib.metadata as importlib_metadata __all__ = ["__version__"] __version__ = importlib_metadata.version("echo") ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570285.61836 echo-0.16.0/echo/vue/0000755000175100017510000000000015252526356013712 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7431731 echo-0.16.0/echo/vue/__init__.py0000644000175100017510000000043115252526320016010 0ustar00runnerrunnertry: from ._autoconnect import autoconnect_callbacks_to_vue # noqa except ImportError: # pragma: no cover pass from ._log import enable_comm_logging, disable_comm_logging # noqa __all__ = ["autoconnect_callbacks_to_vue", "enable_comm_logging", "disable_comm_logging"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.743453 echo-0.16.0/echo/vue/_autoconnect.py0000644000175100017510000003122215252526320016734 0ustar00runnerrunnerimport os import warnings from html.parser import HTMLParser from ..containers import DictCallbackProperty, ListCallbackProperty from ..selection import SelectionCallbackProperty from ._connect import ( connect_any, connect_bool, connect_choice, connect_dict, connect_float, connect_int, connect_list, connect_text, ) from ._log import _enable_comm_logging_if_requested __all__ = ["autoconnect_callbacks_to_vue", "HANDLERS", "TAG_TYPE_MAP"] HANDLERS = { "bool": connect_bool, "int": connect_int, "float": connect_float, "text": connect_text, "selection": connect_choice, "list": connect_list, "dict": connect_dict, "any": connect_any, } TAG_TYPE_MAP = { "v-switch": "bool", "v-checkbox": "bool", "v-text-field": "text", "v-slider": "float", "v-range-slider": "float", "v-select": "selection", "v-combobox": "selection", "v-autocomplete": "selection", } # Attribute names that bind a Vue template expression to a traitlet. _BINDING_ATTRS = {"v-model", ":items", ":value.sync", "v-model.number", "v-model:value"} class _TemplateParser(HTMLParser): """Parse a Vue template and collect traitlet references from binding attributes.""" def __init__(self): super().__init__() self.refs = {} def handle_starttag(self, tag, attrs): attrs_dict = dict(attrs) # Check for binding attributes on this tag bindings = {k: v for k, v in attrs if k in _BINDING_ATTRS and v is not None} if not bindings: return # Determine connection type: echo-type attribute overrides tag inference echo_type = attrs_dict.get("echo-type") if echo_type is None: inferred = TAG_TYPE_MAP.get(tag) if inferred is not None: # For v-text-field with type="number", use int if inferred == "text" and attrs_dict.get("type") == "number": echo_type = "int" else: echo_type = inferred else: for attr_value in bindings.values(): warnings.warn( f"Vue template has binding '{attr_value}' on unknown " f"tag <{tag}> with no echo-type attribute — skipping. " f'Add echo-type="..." to specify the connection type.', stacklevel=2, ) return if echo_type not in HANDLERS: warnings.warn( f"Unknown echo-type '{echo_type}' on <{tag}> — skipping. " f"Supported types: {', '.join(sorted(HANDLERS))}", stacklevel=2, ) return for attr_value in bindings.values(): prop_name = attr_value # Normalize selection suffixes to base property name for suffix in ("_items", "_selected"): if prop_name.endswith(suffix): prop_name = prop_name[: -len(suffix)] break self.refs.setdefault(echo_type, set()).add(prop_name) def _parse_template(template): """ Parse a Vue template string and return a dict mapping handler type codes to sets of property names referenced in the template. The connection type is inferred from the Vue tag (e.g. ``v-switch`` maps to ``bool``). A custom ``echo-type`` attribute on the tag overrides the inferred type. """ parser = _TemplateParser() parser.feed(template) return parser.refs def _resolve_template(widget): """ Resolve the Vue template string from a widget. Checks for a ``template_file`` class attribute (tuple of module path and filename) or a ``template`` traitlet with a ``.template`` string attribute. Returns None if no template can be found. """ for klass in type(widget).__mro__: tf = klass.__dict__.get("template_file") if tf is not None: if isinstance(tf, tuple) and len(tf) == 2: module_file, vue_filename = tf vue_path = os.path.join(os.path.dirname(module_file), vue_filename) if os.path.isfile(vue_path): with open(vue_path) as f: return f.read() break template = getattr(widget, "template", None) if template is not None and hasattr(template, "template"): return template.template return None def _infer_type(instance, prop_name): """Infer the connection type from the callback property descriptor.""" prop = getattr(type(instance), prop_name) if isinstance(prop, SelectionCallbackProperty): return "selection" if isinstance(prop, ListCallbackProperty): return "list" if isinstance(prop, DictCallbackProperty): return "dict" return "any" def _discover_properties(instance): """Return a refs dict for all callback properties on instance.""" refs = {} for name in dir(instance): if not name.startswith("_") and instance.is_callback_property(name): wtype = _infer_type(instance, name) refs.setdefault(wtype, set()).add(name) return refs def _parse_extras(extras): """Parse an extras/only dict into refs and transforms.""" refs = {} transforms = {} for prop_name, spec in extras.items(): if isinstance(spec, tuple): wtype, to_widget, from_widget = spec transforms[prop_name] = (to_widget, from_widget) else: wtype = spec if wtype not in HANDLERS: warnings.warn( f"Unknown type '{wtype}' for extra property " f"'{prop_name}' — skipping. Supported types: " f"{', '.join(sorted(HANDLERS))}", stacklevel=2, ) continue refs.setdefault(wtype, set()).add(prop_name) return refs, transforms def autoconnect_callbacks_to_vue( instance, widget, template=None, extras=None, only=None, skip=None, infer_properties_from="vue", prefix="" ): """ Connect callback properties on ``instance`` to traitlets on ``widget`` bidirectionally. Parameters ---------- instance : HasCallbackProperties The state object with callback properties. widget : HasTraits The ipyvuetify widget. infer_properties_from : ``'vue'`` or ``'python'`` How to discover which properties to connect: * ``'vue'`` (default): parse the Vue template to find ``v-model`` / ``:value.sync`` bindings and infer types from the Vue tags (e.g. ``v-switch`` → bool, ``v-slider`` → float). Use ``extras`` for properties the parser cannot discover. * ``'python'``: discover all callback properties on ``instance`` and infer types from the property descriptors (``ListCallbackProperty`` → list, ``DictCallbackProperty`` → dict, ``SelectionCallbackProperty`` → selection, others → any). template : str, optional The Vue template string. Only used when ``infer_properties_from='vue'``. If not provided, the template is resolved from the widget's ``template_file`` class attribute or ``template`` traitlet. extras : dict, optional Additional properties to connect that are not discovered automatically. Values can be: * A type string: ``'bool'``, ``'int'``, ``'float'``, ``'text'``, ``'selection'``, ``'list'``, ``'dict'``, or ``'any'``. * A tuple of ``(type, to_widget, from_widget)`` to supply custom transforms. only : set or dict, optional When provided, connect *only* the listed properties (skip automatic discovery). Can be a set of property names (types auto-inferred) or a dict with the same value format as ``extras``. skip : set, optional Property names to skip (no warning, no connection). prefix : str, optional A prefix to prepend to the widget traitlet names. For example, with ``prefix='state_'``, a callback property ``x_min`` will sync to a widget traitlet named ``state_x_min``. When using ``infer_properties_from='vue'``, the template should reference the prefixed names (e.g. ``state_x_min``). Returns ------- dict Mapping of property names to connection handler objects. """ if only is not None: if isinstance(only, set): refs = {} transforms = {} for prop_name in only: wtype = _infer_type(instance, prop_name) refs.setdefault(wtype, set()).add(prop_name) else: refs, transforms = _parse_extras(only) elif infer_properties_from == "python": refs = _discover_properties(instance) transforms = {} if extras: extra_refs, transforms = _parse_extras(extras) extra_props = {p for names in extra_refs.values() for p in names} for wtype in refs: refs[wtype] -= extra_props for wtype, prop_names in extra_refs.items(): refs.setdefault(wtype, set()).update(prop_names) else: if template is None: template = _resolve_template(widget) if template is None: raise ValueError( "No Vue template found. Pass template= explicitly or " "ensure the widget has a template_file class attribute " "or template traitlet." ) refs = _parse_template(template) transforms = {} # When a prefix is set, template bindings use prefixed names # (e.g. state_x_min) — strip the prefix to get callback property names. if prefix: stripped = {} for wtype, names in refs.items(): stripped[wtype] = {name[len(prefix) :] if name.startswith(prefix) else name for name in names} refs = stripped if extras: extra_refs, transforms = _parse_extras(extras) # Extras override any template-discovered type for the same # property (e.g. a v-select bound to a non-selection property # that is handled via a text transform instead). extra_props = {p for names in extra_refs.values() for p in names} for wtype in refs: refs[wtype] -= extra_props for wtype, prop_names in extra_refs.items(): refs.setdefault(wtype, set()).update(prop_names) if skip: for wtype in refs: refs[wtype] -= skip _enable_comm_logging_if_requested(widget) connections = {} # Create connections with initial_sync=False so traits are added # without the sync tag, avoiding per-trait comm messages. for wtype, prop_names in refs.items(): handler_cls = HANDLERS[wtype] for prop_name in prop_names: if not instance.is_callback_property(prop_name): widget_prop_check = prefix + prop_name if prefix else prop_name if widget.has_trait(widget_prop_check): continue warnings.warn( f"Vue template references '{prop_name}' (type={wtype}) " f"but '{prop_name}' is not a callback property on " f"{type(instance).__name__}", stacklevel=2, ) continue to_w, from_w = transforms.get(prop_name, (None, None)) if prefix: if wtype == "selection": widget_prop = prefix + prop_name + "_selected" else: widget_prop = prefix + prop_name else: widget_prop = None handler = handler_cls( instance, prop_name, widget, widget_prop=widget_prop, to_widget=to_w, from_widget=from_w, initial_sync=False, ) connections[prop_name] = handler # Set the initial values, enable sync, and send all state in one # comm message rather than one per trait. sync_keys = set() for handler in connections.values(): handler._from_state() handler.enable_widget_sync() sync_keys.update(handler._sync_trait_names()) if hasattr(widget, "send_state") and sync_keys: widget.send_state(key=sync_keys) # Register hold_sync so that delay_callback batches widget updates # into a single comm message instead of one per property. if hasattr(widget, "hold_sync") and hasattr(instance, "_notify_context_managers"): instance._notify_context_managers.append(widget.hold_sync) return connections ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.743453 echo-0.16.0/echo/vue/_connect.py0000644000175100017510000003057215252526320016052 0ustar00runnerrunner# The classes in this module are used to connect callback properties to # ipywidgets/ipyvuetify traitlets. Each connection is atomic per-property: # changing a property on the widget only syncs that single property to the # state, avoiding stale-overwrite issues that arise from syncing the entire # state dict at once. import traitlets from ..containers import CallbackDict, CallbackList from ..core import add_callback, remove_callback from ..selection import ChoiceSeparator __all__ = [ "connect_bool", "connect_int", "connect_float", "connect_text", "connect_choice", "connect_list", "connect_dict", "connect_any", "BaseConnection", ] class BaseConnection: """ Base class for bidirectional connections between a callback property and an ipywidgets/ipyvuetify traitlet. Parameters ---------- instance : object The class instance that the callback property is attached to. prop : str The name of the callback property. widget : HasTraits The ipywidgets/ipyvuetify widget. widget_prop : str, optional The name of the traitlet on the widget. Defaults to ``prop``. to_widget : callable, optional Transform applied when syncing state -> widget. Receives the raw property value and returns the value to set on the traitlet. Overrides the subclass default conversion. from_widget : callable, optional Transform applied when syncing widget -> state. Receives the traitlet value and returns the value to set on the property. Overrides the subclass default conversion. """ # Subclasses set this to a callable returning a traitlet instance, # used when the widget does not already have a matching trait. _default_trait = None def __init__(self, instance, prop, widget, widget_prop=None, to_widget=None, from_widget=None, initial_sync=True): if widget_prop is None: widget_prop = prop if self._default_trait and not widget.has_trait(widget_prop): trait = self._default_trait() if not initial_sync: # Omit sync tag so add_traits doesn't send state; # enable_widget_sync() must be called later. trait.metadata.pop("sync", None) widget.add_traits(**{widget_prop: trait}) self._instance = instance self._prop = prop self._widget = widget self._widget_prop = widget_prop self._to_widget_transform = to_widget self._from_widget_transform = from_widget self._updating = False self.connect(initial_sync=initial_sync) def _from_state(self, *args): if self._updating: return self._updating = True try: value = getattr(self._instance, self._prop) self.update_widget(value) finally: self._updating = False def _from_widget(self, change): if self._updating: return self._updating = True try: self.update_prop(change["new"]) finally: self._updating = False def update_prop(self, value): if self._from_widget_transform is not None: value = self._from_widget_transform(value) setattr(self._instance, self._prop, value) def connect(self, initial_sync=True): add_callback(self._instance, self._prop, self._from_state) self._widget.observe(self._from_widget, names=[self._widget_prop]) if initial_sync: self._from_state() def enable_widget_sync(self): """Tag the widget trait(s) as sync=True and register in widget.keys.""" for name in self._sync_trait_names(): trait = self._widget.traits()[name] if "sync" not in trait.metadata: trait.tag(sync=True) if hasattr(self._widget, "keys"): self._widget.keys.append(name) def _sync_trait_names(self): return [self._widget_prop] def disconnect(self): remove_callback(self._instance, self._prop, self._from_state) self._widget.unobserve(self._from_widget, names=[self._widget_prop]) class connect_bool(BaseConnection): """Connect a boolean callback property to a Bool traitlet.""" _default_trait = staticmethod(lambda: traitlets.Bool(False).tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = bool(value) if value is not None else False setattr(self._widget, self._widget_prop, value) class connect_int(BaseConnection): """Connect an integer callback property to an Int traitlet.""" _default_trait = staticmethod(lambda: traitlets.CInt(0).tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = int(value) if value is not None else 0 setattr(self._widget, self._widget_prop, value) class connect_float(BaseConnection): """Connect a numeric callback property to a Float traitlet.""" _default_trait = staticmethod(lambda: traitlets.Float(allow_none=True).tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = float(value) if value is not None else None setattr(self._widget, self._widget_prop, value) class connect_text(BaseConnection): """Connect a string callback property to a Unicode traitlet.""" _default_trait = staticmethod(lambda: traitlets.Unicode("").tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = str(value) if value is not None else "" setattr(self._widget, self._widget_prop, value) class connect_choice(BaseConnection): """ Connect a SelectionCallbackProperty to a pair of traitlets: ``{prop}_items`` (List) and ``{prop}_selected`` (Int). """ def __init__(self, instance, prop, widget, widget_prop=None, initial_sync=True, **kwargs): if widget_prop is None: widget_prop = f"{prop}_selected" items_prop = widget_prop.replace("_selected", "_items") traits = {} if not widget.has_trait(widget_prop): trait = traitlets.Int(allow_none=True) if initial_sync: trait.tag(sync=True) traits[widget_prop] = trait if not widget.has_trait(items_prop): trait = traitlets.List() if initial_sync: trait.tag(sync=True) traits[items_prop] = trait if traits: widget.add_traits(**traits) self._items_prop = items_prop super().__init__(instance, prop, widget, widget_prop, initial_sync=initial_sync, **kwargs) def _get_choices(self): prop_descriptor = getattr(type(self._instance), self._prop) display_func = prop_descriptor.get_display_func(self._instance) or str choices = [] labels = [] for choice in prop_descriptor.get_choices(self._instance): if not isinstance(choice, ChoiceSeparator): choices.append(choice) labels.append(display_func(choice)) return choices, labels def _from_state(self, *args): if self._updating: return self._updating = True try: choices, labels = self._get_choices() items = [{"text": label, "title": label, "value": i} for i, label in enumerate(labels)] setattr(self._widget, self._items_prop, items) current = getattr(self._instance, self._prop) if current in choices: setattr(self._widget, self._widget_prop, choices.index(current)) else: setattr(self._widget, self._widget_prop, None) finally: self._updating = False def _sync_trait_names(self): return [self._widget_prop, self._items_prop] def update_widget(self, value): # pragma: no cover pass # Handled by _from_state def update_prop(self, index): if index is None: return choices, _ = self._get_choices() if 0 <= index < len(choices): setattr(self._instance, self._prop, choices[index]) def _to_plain(value): """Recursively convert CallbackList/CallbackDict to plain Python types.""" if isinstance(value, dict): return {k: _to_plain(v) for k, v in value.items()} elif isinstance(value, list): return [_to_plain(v) for v in value] return value def _update_list_in_place(callback_list, new_values): """Update a CallbackList in place from a plain list.""" for i in range(min(len(callback_list), len(new_values))): old_item = callback_list[i] new_item = new_values[i] if isinstance(old_item, dict) and isinstance(new_item, dict): _update_dict_in_place(old_item, new_item) elif isinstance(old_item, list) and isinstance(new_item, list): _update_list_in_place(old_item, new_item) elif old_item != new_item: callback_list[i] = new_item while len(callback_list) > len(new_values): callback_list.pop() for i in range(len(callback_list), len(new_values)): callback_list.append(new_values[i]) def _update_dict_in_place(callback_dict, new_values): """Update a CallbackDict in place from a plain dict.""" for key, value in new_values.items(): if key in callback_dict: old_value = callback_dict[key] if isinstance(old_value, dict) and isinstance(value, dict): _update_dict_in_place(old_value, value) elif isinstance(old_value, list) and isinstance(value, list): _update_list_in_place(old_value, value) elif old_value != value: callback_dict[key] = value else: callback_dict[key] = value for key in set(callback_dict) - set(new_values): callback_dict.pop(key) class connect_list(BaseConnection): """Connect a ListCallbackProperty or list-valued CallbackProperty to a List traitlet.""" _default_trait = staticmethod(lambda: traitlets.List().tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = _to_plain(value) if value is not None else [] setattr(self._widget, self._widget_prop, value) def update_prop(self, value): if self._from_widget_transform is not None: value = self._from_widget_transform(value) setattr(self._instance, self._prop, value) else: container = getattr(self._instance, self._prop) if isinstance(container, CallbackList): _update_list_in_place(container, value) else: setattr(self._instance, self._prop, list(value)) class connect_dict(BaseConnection): """Connect a DictCallbackProperty or dict-valued CallbackProperty to a Dict traitlet.""" _default_trait = staticmethod(lambda: traitlets.Dict().tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = _to_plain(value) if value is not None else {} setattr(self._widget, self._widget_prop, value) def update_prop(self, value): if self._from_widget_transform is not None: value = self._from_widget_transform(value) setattr(self._instance, self._prop, value) else: container = getattr(self._instance, self._prop) if isinstance(container, CallbackDict): _update_dict_in_place(container, value) else: setattr(self._instance, self._prop, dict(value)) class connect_any(BaseConnection): """Connect a CallbackProperty to an Any traitlet (no type coercion).""" _default_trait = staticmethod(lambda: traitlets.Any().tag(sync=True)) def update_widget(self, value): if self._to_widget_transform is not None: value = self._to_widget_transform(value) else: value = _to_plain(value) setattr(self._widget, self._widget_prop, value) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.743453 echo-0.16.0/echo/vue/_log.py0000644000175100017510000000401315252526320015171 0ustar00runnerrunnerimport logging logger = logging.getLogger("echo") # Global flag: when True, new widgets are automatically instrumented _comm_logging_enabled = False # Maps widget id -> (original_send, original_set_state, widget) _patched_widgets = {} def enable_comm_logging(): """ Enable logging of comm messages (state updates) between Python and the browser frontend for all widgets connected via :func:`~echo.vue.autoconnect_callbacks_to_vue` after this call. Messages are emitted at ``DEBUG`` level on the ``echo`` logger. """ global _comm_logging_enabled _comm_logging_enabled = True def disable_comm_logging(): """ Disable comm logging. Widgets already instrumented are unpatched, and future widgets will not be instrumented. """ global _comm_logging_enabled _comm_logging_enabled = False for widget_id in list(_patched_widgets): original_send, original_set_state, widget = _patched_widgets.pop(widget_id) widget._send = original_send widget.set_state = original_set_state def _enable_comm_logging_if_requested(widget): """Patch a single widget if comm logging is enabled and not already patched.""" if not _comm_logging_enabled: return widget_id = id(widget) if widget_id in _patched_widgets: return if not hasattr(widget, "_send") or not hasattr(widget, "set_state"): return original_send = widget._send original_set_state = widget.set_state _patched_widgets[widget_id] = (original_send, original_set_state, widget) label = type(widget).__name__ def _logged_send(msg, buffers=None): if msg.get("method") == "update": state = msg.get("state", {}) logger.debug("[PY->VUE] %s: %s", label, repr(state)) return original_send(msg, buffers=buffers) def _logged_set_state(sync_data): logger.debug("[VUE->PY] %s: %s", label, repr(sync_data)) return original_set_state(sync_data) widget._send = _logged_send widget.set_state = _logged_set_state ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570285.619155 echo-0.16.0/echo/vue/tests/0000755000175100017510000000000015252526356015054 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570255.743453 echo-0.16.0/echo/vue/tests/__init__.py0000644000175100017510000000000015252526320017142 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7437346 echo-0.16.0/echo/vue/tests/test_autoconnect.py0000644000175100017510000006326515252526320021012 0ustar00runnerrunnerimport warnings import pytest traitlets = pytest.importorskip("traitlets") from echo import ( # noqa: E402 # noqa: E402 CallbackProperty, DictCallbackProperty, HasCallbackProperties, ListCallbackProperty, SelectionCallbackProperty, ) from echo.vue._autoconnect import _parse_template, _resolve_template, autoconnect_callbacks_to_vue # noqa: E402 # Template uses tag-based type inference: the tag determines the handler type # and the v-model value is the property name directly. TEMPLATE = """ """ class ViewerState(HasCallbackProperties): x_log = CallbackProperty(False) y_log = CallbackProperty(False) show_axes = CallbackProperty(True) x_min = CallbackProperty(-10.0) x_max = CallbackProperty(30.0) title = CallbackProperty("My Plot") x_att = SelectionCallbackProperty(default_index=0) # Property NOT in the template: dpi = CallbackProperty(72) def __init__(self): super().__init__() ViewerState.x_att.set_choices(self, ["x", "y", "z"]) class SimpleWidget(traitlets.HasTraits): pass # --- Template mode tests --- def test_parse_template(): refs = _parse_template(TEMPLATE) assert refs["bool"] == {"x_log", "y_log", "show_axes"} assert refs["float"] == {"x_min", "x_max"} assert refs["text"] == {"title"} assert refs["selection"] == {"x_att"} def test_template_creates_only_referenced_traits(): state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) # Properties referenced in template should be connected assert "x_log" in handlers assert "show_axes" in handlers assert "x_min" in handlers assert "title" in handlers assert "x_att" in handlers # Traitlets use property names directly (no type prefix) assert hasattr(widget, "x_log") assert hasattr(widget, "x_min") assert hasattr(widget, "title") assert hasattr(widget, "x_att_items") assert hasattr(widget, "x_att_selected") # Property NOT in template should NOT be connected assert "dpi" not in handlers def test_template_bool_bidirectional(): state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) assert widget.x_log is False assert widget.show_axes is True state.x_log = True assert widget.x_log is True widget.x_log = False assert state.x_log is False def test_template_value_bidirectional(): state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) assert widget.x_min == -10.0 state.x_min = 5.0 assert widget.x_min == 5.0 widget.x_max = 100.0 assert state.x_max == 100.0 def test_template_text_bidirectional(): state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) assert widget.title == "My Plot" state.title = "New Title" assert widget.title == "New Title" widget.title = "From Widget" assert state.title == "From Widget" def test_template_choice_bidirectional(): state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) items = widget.x_att_items assert len(items) == 3 assert items[0]["text"] == "x" state.x_att = "z" assert widget.x_att_selected == 2 widget.x_att_selected = 1 assert state.x_att == "y" def test_template_atomic_no_stale_overwrite(): """Setting a bool via the widget only syncs that one property.""" state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) set_props = [] original_setattr = state.__class__.__setattr__ def tracking_setattr(self, name, value): if not name.startswith("_"): set_props.append(name) original_setattr(self, name, value) state.__class__.__setattr__ = tracking_setattr try: set_props.clear() widget.x_log = True assert set_props == ["x_log"] finally: state.__class__.__setattr__ = original_setattr def test_template_warns_on_unmatched_ref(): template = '' state = ViewerState() widget = SimpleWidget() with pytest.warns(UserWarning, match="'nonexistent' is not a callback property"): autoconnect_callbacks_to_vue(state, widget, template=template) def test_no_warning_when_widget_has_trait(): """No warning when template references a trait that exists on the widget but not on the state.""" template = '' state = ViewerState() class WidgetWithTrait(traitlets.HasTraits): custom_txt = traitlets.Unicode("hello").tag(sync=True) widget = WidgetWithTrait() with warnings.catch_warnings(): warnings.simplefilter("error") handlers = autoconnect_callbacks_to_vue(state, widget, template=template) # custom_txt should be skipped (not connected), x_log should be connected assert "custom_txt" not in handlers assert "x_log" in handlers def test_template_empty(): state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, template="") assert len(handlers) == 0 def test_no_template_raises(): state = ViewerState() widget = SimpleWidget() with pytest.raises(ValueError, match="No Vue template found"): autoconnect_callbacks_to_vue(state, widget) def test_echo_type_override(): """echo-type attribute overrides tag-based inference.""" template = """ """ refs = _parse_template(template) assert "float" in refs assert "x_min" in refs["float"] assert "text" not in refs def test_text_field_number_type(): """v-text-field with type='number' infers int.""" template = """ """ refs = _parse_template(template) assert "int" in refs assert "age" in refs["int"] assert "text" not in refs @pytest.mark.parametrize("binding", [':value.sync="x_min"', 'v-model:value="x_min"']) def test_unknown_tag_warns(binding): """Unknown tag without echo-type warns and skips.""" template = f"" state = ViewerState() widget = SimpleWidget() with pytest.warns(UserWarning, match="unknown tag.*no echo-type"): handlers = autoconnect_callbacks_to_vue(state, widget, template=template) assert len(handlers) == 0 def test_unknown_tag_with_echo_type(): """Unknown tag with echo-type works correctly.""" template = '' state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, template=template) assert "x_min" in handlers assert widget.x_min == -10.0 state.x_min = 42.0 assert widget.x_min == 42.0 def test_invalid_echo_type_warns(): """Invalid echo-type value warns and skips.""" template = '' refs = _parse_template(template) assert len(refs) == 0 def test_resolve_template_from_template_file(tmp_path): """_resolve_template reads a .vue file via template_file class attribute.""" vue_file = tmp_path / "test.vue" vue_file.write_text('') class WidgetWithTemplateFile(traitlets.HasTraits): template_file = (str(vue_file), "test.vue") widget = WidgetWithTemplateFile() result = _resolve_template(widget) assert result == '' def test_resolve_template_from_template_traitlet(): """_resolve_template falls back to widget.template.template.""" class TemplateHolder: template = '' class WidgetWithTemplate(traitlets.HasTraits): pass widget = WidgetWithTemplate() widget.template = TemplateHolder() result = _resolve_template(widget) assert result == '' def test_resolve_template_invalid_template_file(): """_resolve_template skips template_file that isn't a valid tuple.""" class WidgetWithBadTemplateFile(traitlets.HasTraits): template_file = "not-a-tuple" widget = WidgetWithBadTemplateFile() assert _resolve_template(widget) is None def test_resolve_template_returns_none(): """_resolve_template returns None when no template is found.""" widget = SimpleWidget() assert _resolve_template(widget) is None def test_extras_connect(): """extras dict connects properties not in the template.""" template = '' state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"show_axes": "bool", "x_min": "float", "title": "text"}, ) assert "x_log" in handlers assert "show_axes" in handlers assert "x_min" in handlers assert "title" in handlers # Verify bidirectional sync works for extras state.show_axes = False assert widget.show_axes is False widget.x_min = 99.0 assert state.x_min == 99.0 def test_extras_invalid_type_warns(): """extras with an invalid type warns and skips.""" template = "" state = ViewerState() widget = SimpleWidget() with pytest.warns(UserWarning, match="Unknown type 'bogus'"): handlers = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"x_log": "bogus"}, ) assert len(handlers) == 0 def test_extras_missing_property_warns(): """extras referencing a non-existent property warns and skips.""" template = "" state = ViewerState() widget = SimpleWidget() with pytest.warns(UserWarning, match="not a callback property"): handlers = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"nonexistent": "bool"}, ) assert len(handlers) == 0 def test_extras_choice(): """extras can connect SelectionCallbackProperty via selection.""" template = "" state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"x_att": "selection"}, ) assert "x_att" in handlers assert hasattr(widget, "x_att_items") assert hasattr(widget, "x_att_selected") assert len(widget.x_att_items) == 3 def test_extras_with_transforms(): """extras tuple form applies custom transforms.""" template = "" state = ViewerState() widget = SimpleWidget() connections = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"x_min": ("text", lambda v: f"val={v}", lambda s: float(s.split("=")[1]))}, ) assert "x_min" in connections # to_widget transform applied assert widget.x_min == "val=-10.0" # State change goes through to_widget state.x_min = 5.0 assert widget.x_min == "val=5.0" # Widget change goes through from_widget widget.x_min = "val=42.0" assert state.x_min == 42.0 def test_extras_override_template_type(): """extras override the template-inferred type for the same property.""" # Template infers x_min as 'float' from v-slider, but extras override to 'text' template = '' state = ViewerState() widget = SimpleWidget() connections = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"x_min": ("text", str, float)}, ) assert "x_min" in connections # Should be a text traitlet (from extras), not a float (from template) assert widget.x_min == "-10.0" def test_skip(): """skip prevents listed properties from being connected or warned about.""" state = ViewerState() widget = SimpleWidget() connections = autoconnect_callbacks_to_vue( state, widget, template=TEMPLATE, skip={"x_min", "title"}, ) assert "x_min" not in connections assert "title" not in connections assert "x_log" in connections assert not hasattr(widget, "x_min") assert not hasattr(widget, "title") def test_only_skips_template(): """only connects listed properties without parsing a template.""" state = ViewerState() widget = SimpleWidget() connections = autoconnect_callbacks_to_vue( state, widget, only={"x_log": "bool", "x_min": "float"}, ) assert set(connections) == {"x_log", "x_min"} # Not in only → not connected assert not hasattr(widget, "title") state.x_log = True assert widget.x_log is True widget.x_min = 99.0 assert state.x_min == 99.0 def test_only_with_transforms(): """only supports transform tuples.""" state = ViewerState() widget = SimpleWidget() autoconnect_callbacks_to_vue( state, widget, only={"x_min": ("text", str, float)}, ) assert widget.x_min == "-10.0" widget.x_min = "7.5" assert state.x_min == 7.5 # --- List and Dict connection tests --- class ContainerState(HasCallbackProperties): items = ListCallbackProperty([{"name": "a"}, {"name": "b"}]) settings = DictCallbackProperty({"visible": True, "nested": {"x": 1, "y": 2}}) tags = ListCallbackProperty(["red", "green"]) def test_list_state_to_widget(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"items": "list"}) assert widget.items == [{"name": "a"}, {"name": "b"}] def test_list_state_mutation_syncs(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"items": "list"}) state.items.append({"name": "c"}) assert len(widget.items) == 3 assert widget.items[2] == {"name": "c"} def test_list_widget_to_state(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"items": "list"}) widget.items = [{"name": "x"}] assert len(state.items) == 1 assert state.items[0] == {"name": "x"} def test_list_widget_updates_in_place(): """Widget changes update the existing CallbackList rather than replacing it.""" state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"items": "list"}) original_list = state.items widget.items = [{"name": "x"}, {"name": "y"}] assert state.items is original_list def test_list_simple_values(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"tags": "list"}) assert widget.tags == ["red", "green"] state.tags.append("blue") assert widget.tags == ["red", "green", "blue"] widget.tags = ["one"] assert list(state.tags) == ["one"] def test_dict_state_to_widget(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) assert widget.settings == {"visible": True, "nested": {"x": 1, "y": 2}} def test_dict_state_mutation_syncs(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) state.settings["visible"] = False assert widget.settings["visible"] is False def test_dict_nested_mutation_syncs(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) state.settings["nested"]["x"] = 99 assert widget.settings["nested"]["x"] == 99 def test_dict_widget_to_state(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) widget.settings = {"visible": False, "nested": {"x": 5, "y": 6}} assert state.settings["visible"] is False assert state.settings["nested"]["x"] == 5 def test_dict_widget_updates_in_place(): """Widget changes update the existing CallbackDict rather than replacing it.""" state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) original_dict = state.settings original_nested = state.settings["nested"] widget.settings = {"visible": False, "nested": {"x": 5, "y": 6}} assert state.settings is original_dict assert state.settings["nested"] is original_nested def test_dict_widget_adds_new_keys(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) widget.settings = {"visible": True, "nested": {"x": 1, "y": 2}, "new_key": "hello"} assert state.settings["new_key"] == "hello" def test_dict_widget_removes_keys(): state = ContainerState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"settings": "dict"}) widget.settings = {"visible": True} assert "nested" not in state.settings def test_list_plain_callback_property(): """connect_list works with a regular CallbackProperty holding a plain list.""" class PlainListState(HasCallbackProperties): open_panels = CallbackProperty([]) state = PlainListState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"open_panels": "list"}) assert widget.open_panels == [] state.open_panels = [0, 2] assert widget.open_panels == [0, 2] # Widget→state does full replacement (not in-place) for plain lists widget.open_panels = [1, 3] assert state.open_panels == [1, 3] def test_list_and_dict_via_extras(): template = '' class MixedState(HasCallbackProperties): flag = CallbackProperty(True) items = ListCallbackProperty([1, 2, 3]) config = DictCallbackProperty({"a": 1}) state = MixedState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue( state, widget, template=template, extras={"items": "list", "config": "dict"}, ) assert "flag" in handlers assert "items" in handlers assert "config" in handlers assert widget.items == [1, 2, 3] assert widget.config == {"a": 1} # --- Auto-inferred type tests --- def test_only_as_set_infers_types(): """only as a set auto-infers types from property descriptors.""" class AutoState(HasCallbackProperties): name = CallbackProperty("hello") visible = CallbackProperty(True) items = ListCallbackProperty([1, 2]) config = DictCallbackProperty({"a": 1}) choice = SelectionCallbackProperty(default_index=0) AutoState.choice.set_choices(AutoState, ["x", "y"]) state = AutoState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue( state, widget, only={"name", "visible", "items", "config", "choice"}, ) assert set(handlers) == {"name", "visible", "items", "config", "choice"} # Scalar properties use Any traitlet — bidirectional sync works assert widget.name == "hello" state.name = "world" assert widget.name == "world" widget.name = "back" assert state.name == "back" assert widget.visible is True state.visible = False assert widget.visible is False # List inferred from ListCallbackProperty assert widget.items == [1, 2] state.items.append(3) assert widget.items == [1, 2, 3] # Dict inferred from DictCallbackProperty assert widget.config == {"a": 1} state.config["b"] = 2 assert widget.config == {"a": 1, "b": 2} # Selection inferred from SelectionCallbackProperty assert hasattr(widget, "choice_items") assert hasattr(widget, "choice_selected") def test_any_handles_plain_list_value(): """connect_any works for a CallbackProperty whose value is a plain list.""" class PlainState(HasCallbackProperties): open_panels = CallbackProperty([]) state = PlainState() widget = SimpleWidget() autoconnect_callbacks_to_vue(state, widget, only={"open_panels"}) assert widget.open_panels == [] state.open_panels = [0, 2] assert widget.open_panels == [0, 2] widget.open_panels = [1] assert state.open_panels == [1] def test_infer_from_python_discovers_all(): """infer_properties_from='python' discovers all callback properties.""" class FullState(HasCallbackProperties): name = CallbackProperty("hi") items = ListCallbackProperty([1]) config = DictCallbackProperty({"a": 1}) _private = CallbackProperty("skip") # should be skipped (starts with _) state = FullState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, infer_properties_from="python") assert set(handlers) == {"name", "items", "config"} state.name = "bye" assert widget.name == "bye" state.items.append(2) assert widget.items == [1, 2] state.config["b"] = 2 assert widget.config == {"a": 1, "b": 2} def test_infer_from_python_with_skip(): """infer_properties_from='python' respects the skip parameter.""" class SkipState(HasCallbackProperties): a = CallbackProperty(1) b = CallbackProperty(2) c = CallbackProperty(3) state = SkipState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, infer_properties_from="python", skip={"b"}) assert "a" in handlers assert "b" not in handlers assert "c" in handlers def test_infer_from_python_with_extras(): """infer_properties_from='python' + extras for custom transforms.""" class TransformState(HasCallbackProperties): name = CallbackProperty("hello") value = CallbackProperty(10) state = TransformState() widget = SimpleWidget() autoconnect_callbacks_to_vue( state, widget, infer_properties_from="python", extras={"value": ("text", str, int)}, ) # name auto-inferred as 'any', value overridden to 'text' with transforms assert widget.name == "hello" assert widget.value == "10" widget.value = "42" assert state.value == 42 # --- Prefix tests --- PREFIXED_TEMPLATE = """ """ def test_prefix_template_bidirectional(): """prefix maps callback properties to prefixed widget traitlets.""" state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, template=PREFIXED_TEMPLATE, prefix="state_") assert "x_log" in handlers assert "x_min" in handlers assert "title" in handlers assert "x_att" in handlers # Widget traits should have the prefix assert hasattr(widget, "state_x_log") assert hasattr(widget, "state_x_min") assert hasattr(widget, "state_title") assert hasattr(widget, "state_x_att_items") assert hasattr(widget, "state_x_att_selected") # Unprefixed traits should NOT exist assert not hasattr(widget, "x_log") assert not hasattr(widget, "x_min") assert not hasattr(widget, "title") # Bool: state -> widget -> state assert widget.state_x_log is False state.x_log = True assert widget.state_x_log is True widget.state_x_log = False assert state.x_log is False # Float: state -> widget -> state assert widget.state_x_min == -10.0 state.x_min = 5.0 assert widget.state_x_min == 5.0 widget.state_x_min = 99.0 assert state.x_min == 99.0 # Text: state -> widget -> state assert widget.state_title == "My Plot" state.title = "New" assert widget.state_title == "New" widget.state_title = "From Widget" assert state.title == "From Widget" # Selection: state -> widget -> state assert len(widget.state_x_att_items) == 3 state.x_att = "z" assert widget.state_x_att_selected == 2 widget.state_x_att_selected = 1 assert state.x_att == "y" def test_prefix_only(): """prefix works with the only parameter.""" state = ViewerState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, only={"x_min": "float", "x_log": "bool"}, prefix="s_") assert set(handlers) == {"x_min", "x_log"} assert hasattr(widget, "s_x_min") assert hasattr(widget, "s_x_log") state.x_min = 42.0 assert widget.s_x_min == 42.0 widget.s_x_log = True assert state.x_log is True def test_prefix_infer_from_python(): """prefix works with infer_properties_from='python'.""" class SmallState(HasCallbackProperties): value = CallbackProperty(10) state = SmallState() widget = SimpleWidget() handlers = autoconnect_callbacks_to_vue(state, widget, infer_properties_from="python", prefix="vue_") assert "value" in handlers assert hasattr(widget, "vue_value") state.value = 99 assert widget.vue_value == 99 widget.vue_value = 7 assert state.value == 7 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7437346 echo-0.16.0/echo/vue/tests/test_connect.py0000644000175100017510000003275315252526320020117 0ustar00runnerrunnerimport pytest traitlets = pytest.importorskip("traitlets") ipywidgets = pytest.importorskip("ipywidgets") from echo import ( # noqa: E402 # noqa: E402 CallbackProperty, DictCallbackProperty, HasCallbackProperties, ListCallbackProperty, SelectionCallbackProperty, delay_callback, ) from echo.vue._autoconnect import autoconnect_callbacks_to_vue # noqa: E402 from echo.vue._connect import ( # noqa: E402 connect_any, connect_bool, connect_choice, connect_dict, connect_float, connect_list, connect_text, ) class SimpleState(HasCallbackProperties): flag = CallbackProperty(False) height = CallbackProperty(1.5) count = CallbackProperty(10) name = CallbackProperty("default") age = CallbackProperty(25) color = SelectionCallbackProperty(default_index=0) def __init__(self): super().__init__() SimpleState.color.set_choices(self, ["red", "green", "blue"]) SimpleState.color.set_display_func(self, str.upper) class SimpleWidget(traitlets.HasTraits): pass class TestConnectBool: def setup_method(self): self.state = SimpleState() self.widget = SimpleWidget() self.widget.add_traits(flag=traitlets.Bool(False).tag(sync=True)) self.conn = connect_bool(self.state, "flag", self.widget, "flag") def test_state_to_widget(self): self.state.flag = True assert self.widget.flag is True self.state.flag = False assert self.widget.flag is False def test_widget_to_state(self): self.widget.flag = True assert self.state.flag is True self.widget.flag = False assert self.state.flag is False def test_initial_sync(self): assert self.widget.flag == self.state.flag def test_disconnect(self): self.conn.disconnect() self.state.flag = True assert self.widget.flag is False def test_auto_create_trait(self): state = SimpleState() widget = SimpleWidget() conn = connect_bool(state, "flag", widget) assert hasattr(widget, "flag") state.flag = True assert widget.flag is True conn.disconnect() class TestConnectValue: def setup_method(self): self.state = SimpleState() self.widget = SimpleWidget() self.widget.add_traits(height=traitlets.Float(allow_none=True).tag(sync=True)) self.conn = connect_float(self.state, "height", self.widget, "height") def test_state_to_widget(self): self.state.height = 2.5 assert self.widget.height == 2.5 def test_widget_to_state(self): self.widget.height = 3.0 assert self.state.height == 3.0 def test_initial_sync(self): assert self.widget.height == 1.5 def test_none_handling(self): self.state.height = None assert self.widget.height is None def test_auto_create_trait(self): state = SimpleState() widget = SimpleWidget() conn = connect_float(state, "count", widget) assert hasattr(widget, "count") state.count = 42 assert widget.count == 42.0 conn.disconnect() class TestConnectText: def setup_method(self): self.state = SimpleState() self.widget = SimpleWidget() self.widget.add_traits(name=traitlets.Unicode("").tag(sync=True)) self.conn = connect_text(self.state, "name", self.widget, "name") def test_state_to_widget(self): self.state.name = "hello" assert self.widget.name == "hello" def test_widget_to_state(self): self.widget.name = "world" assert self.state.name == "world" def test_initial_sync(self): assert self.widget.name == "default" def test_none_handling(self): self.state.name = None assert self.widget.name == "" class TestConnectChoice: def setup_method(self): self.state = SimpleState() self.widget = SimpleWidget() self.widget.add_traits( color_items=traitlets.List().tag(sync=True), color_selected=traitlets.Int(allow_none=True).tag(sync=True), ) self.conn = connect_choice( self.state, "color", self.widget, "color_selected", ) def test_items_populated(self): items = self.widget.color_items assert len(items) == 3 assert items[0]["text"] == items[0]["title"] == "RED" assert items[1]["text"] == items[1]["title"] == "GREEN" assert items[2]["text"] == items[2]["title"] == "BLUE" def test_state_to_widget(self): self.state.color = "blue" assert self.widget.color_selected == 2 def test_widget_to_state(self): self.widget.color_selected = 1 assert self.state.color == "green" def test_initial_sync(self): assert self.widget.color_selected == 0 assert self.state.color == "red" def test_auto_create_traits(self): state = SimpleState() widget = SimpleWidget() conn = connect_choice(state, "color", widget) assert hasattr(widget, "color_items") assert hasattr(widget, "color_selected") assert len(widget.color_items) == 3 conn.disconnect() def test_current_not_in_choices(self): """When the current value isn't in choices, selected is set to None.""" state = SimpleState() widget = SimpleWidget() conn = connect_choice(state, "color", widget) assert widget.color_selected == 0 # Temporarily override _get_choices to return choices that don't # include the current value, then trigger a sync. orig = conn._get_choices conn._get_choices = lambda: (["cyan", "magenta"], ["cyan", "magenta"]) conn._updating = False conn._from_state() assert widget.color_selected is None conn._get_choices = orig def test_widget_set_none(self): """Setting selected to None does not change the state.""" state = SimpleState() widget = SimpleWidget() connect_choice(state, "color", widget) current = state.color widget.color_selected = None assert state.color == current def test_widget_set_out_of_range(self): """Setting selected to an out-of-range index does not change the state.""" state = SimpleState() widget = SimpleWidget() connect_choice(state, "color", widget) current = state.color widget.color_selected = 99 assert state.color == current class ContainerState(HasCallbackProperties): items = ListCallbackProperty([{"name": "a"}, {"name": "b"}]) config = DictCallbackProperty({"x": 1, "nested": {"y": 2}}) tags = ListCallbackProperty(["red", "green"]) plain_list = CallbackProperty([0, 1]) class TestConnectList: def setup_method(self): self.state = ContainerState() self.widget = SimpleWidget() self.conn = connect_list(self.state, "items", self.widget) def test_initial_sync(self): assert self.widget.items == [{"name": "a"}, {"name": "b"}] def test_state_to_widget(self): self.state.items.append({"name": "c"}) assert len(self.widget.items) == 3 assert self.widget.items[2] == {"name": "c"} def test_widget_to_state(self): self.widget.items = [{"name": "x"}] assert len(self.state.items) == 1 assert self.state.items[0] == {"name": "x"} def test_in_place_update(self): original = self.state.items self.widget.items = [{"name": "x"}, {"name": "y"}] assert self.state.items is original def test_auto_create_trait(self): state = ContainerState() widget = SimpleWidget() connect_list(state, "tags", widget) assert hasattr(widget, "tags") assert widget.tags == ["red", "green"] def test_disconnect(self): self.conn.disconnect() self.state.items.append({"name": "c"}) assert len(self.widget.items) == 2 def test_plain_list_fallback(self): """CallbackProperty holding a plain list uses setattr instead of in-place.""" state = ContainerState() widget = SimpleWidget() connect_list(state, "plain_list", widget) assert widget.plain_list == [0, 1] widget.plain_list = [3, 4] assert state.plain_list == [3, 4] class TestConnectDict: def setup_method(self): self.state = ContainerState() self.widget = SimpleWidget() self.conn = connect_dict(self.state, "config", self.widget) def test_initial_sync(self): assert self.widget.config == {"x": 1, "nested": {"y": 2}} def test_state_to_widget(self): self.state.config["x"] = 99 assert self.widget.config["x"] == 99 def test_nested_mutation(self): self.state.config["nested"]["y"] = 42 assert self.widget.config["nested"]["y"] == 42 def test_widget_to_state(self): self.widget.config = {"x": 5, "nested": {"y": 6}} assert self.state.config["x"] == 5 assert self.state.config["nested"]["y"] == 6 def test_in_place_update(self): original = self.state.config original_nested = self.state.config["nested"] self.widget.config = {"x": 5, "nested": {"y": 6}} assert self.state.config is original assert self.state.config["nested"] is original_nested def test_auto_create_trait(self): state = ContainerState() widget = SimpleWidget() connect_dict(state, "config", widget) assert hasattr(widget, "config") def test_disconnect(self): self.conn.disconnect() self.state.config["x"] = 99 assert self.widget.config["x"] == 1 class TestConnectAny: def setup_method(self): self.state = SimpleState() self.widget = SimpleWidget() self.conn = connect_any(self.state, "name", self.widget) def test_initial_sync(self): assert self.widget.name == "default" def test_state_to_widget(self): self.state.name = "hello" assert self.widget.name == "hello" def test_widget_to_state(self): self.widget.name = "world" assert self.state.name == "world" def test_auto_create_trait(self): state = SimpleState() widget = SimpleWidget() connect_any(state, "height", widget) assert hasattr(widget, "height") assert widget.height == 1.5 def test_disconnect(self): self.conn.disconnect() self.state.name = "changed" assert self.widget.name == "default" def test_no_type_coercion(self): """connect_any passes values through without coercion.""" state = SimpleState() widget = SimpleWidget() connect_any(state, "flag", widget) state.flag = True assert widget.flag is True state.flag = 0 assert widget.flag == 0 # not coerced to bool @pytest.mark.parametrize( "connect_cls, prop, initial, new_state, new_widget", [ (connect_bool, "flag", False, True, False), (connect_float, "height", 1.5, 3.0, 7.0), (connect_text, "name", "default", "hello", "world"), ], ) def test_transforms(connect_cls, prop, initial, new_state, new_widget): """to_widget and from_widget transforms are applied.""" state = SimpleState() widget = SimpleWidget() conn = connect_cls(state, prop, widget, to_widget=lambda v: v, from_widget=lambda v: v) assert getattr(widget, prop) == initial setattr(state, prop, new_state) assert getattr(widget, prop) == new_state setattr(widget, prop, new_widget) assert getattr(state, prop) == new_widget conn.disconnect() class CommTrackingWidget(ipywidgets.DOMWidget): """DOMWidget subclass that captures comm messages instead of sending them. Uses the real ipywidgets hold_sync/send_state/notify_change pipeline but overrides _send and notify_change to work without a live kernel. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.sends = [] def _send(self, msg, buffers=None): self.sends.append(msg) def notify_change(self, change): # Replicate the ipywidgets notify_change logic but without # the comm/kernel guard that blocks send_state in test envs. name = change["name"] if name in self.keys and self._should_send_property(name, getattr(self, name)): self.send_state(key=name) super(ipywidgets.DOMWidget, self).notify_change(change) class FourPropState(HasCallbackProperties): a = CallbackProperty(0.0) b = CallbackProperty(0.0) c = CallbackProperty(0.0) d = CallbackProperty(0.0) FOUR_SLIDER_TEMPLATE = """ """ def test_delay_callback_batches_comms(): """When delay_callback exits with multiple changed properties, only one comm message should be sent to the frontend.""" state = FourPropState() widget = CommTrackingWidget() autoconnect_callbacks_to_vue(state, widget, template=FOUR_SLIDER_TEMPLATE) # Clear any messages from initial setup widget.sends.clear() with delay_callback(state, "a", "b", "c", "d"): state.a = 1.0 state.b = 2.0 state.c = 3.0 state.d = 4.0 # All four values should have synced to the widget assert widget.a == 1.0 assert widget.b == 2.0 assert widget.c == 3.0 assert widget.d == 4.0 # Should be one batched comm message, not four individual ones assert len(widget.sends) == 1 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7437346 echo-0.16.0/echo/vue/tests/test_log.py0000644000175100017510000000515115252526320017237 0ustar00runnerrunnerimport logging import pytest traitlets = pytest.importorskip("traitlets") from echo import CallbackProperty, HasCallbackProperties # noqa: E402 from echo.vue._autoconnect import autoconnect_callbacks_to_vue # noqa: E402 from echo.vue._log import disable_comm_logging, enable_comm_logging # noqa: E402 TEMPLATE = """ """ class State(HasCallbackProperties): x_min = CallbackProperty(0.0) class Widget(traitlets.HasTraits): """Minimal stand-in for an ipywidget that calls _send on trait changes.""" def _send(self, msg, buffers=None): pass def set_state(self, sync_data): for k, v in sync_data.items(): setattr(self, k, v) def _notify_trait(self, name, old_value, new_value): super()._notify_trait(name, old_value, new_value) trait = self.traits().get(name) if trait and trait.metadata.get("sync"): self._send({"method": "update", "state": {name: new_value}}) @pytest.fixture(autouse=True) def _clean_logging_state(): disable_comm_logging() yield disable_comm_logging() @pytest.fixture def connected_pair(): state = State() widget = Widget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) return state, widget @pytest.fixture def logged_pair(): enable_comm_logging() state = State() widget = Widget() autoconnect_callbacks_to_vue(state, widget, template=TEMPLATE) return state, widget def test_logging_captures_state_to_widget(caplog, logged_pair): state, widget = logged_pair with caplog.at_level(logging.DEBUG, logger="echo"): state.x_min = 5.0 assert any("[PY->VUE]" in r.message for r in caplog.records) def test_logging_captures_widget_to_state(caplog, logged_pair): state, widget = logged_pair with caplog.at_level(logging.DEBUG, logger="echo"): widget.set_state({"x_min": 3.0}) assert any("[VUE->PY]" in r.message for r in caplog.records) def test_no_logging_when_disabled(caplog, connected_pair): state, widget = connected_pair with caplog.at_level(logging.DEBUG, logger="echo"): state.x_min = 5.0 widget.set_state({"x_min": 3.0}) assert not any("[PY->VUE]" in r.message or "[VUE->PY]" in r.message for r in caplog.records) def test_disable_stops_logging(caplog, logged_pair): state, widget = logged_pair disable_comm_logging() with caplog.at_level(logging.DEBUG, logger="echo"): state.x_min = 5.0 widget.set_state({"x_min": 3.0}) assert not any("[PY->VUE]" in r.message or "[VUE->PY]" in r.message for r in caplog.records) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570285.614236 echo-0.16.0/echo.egg-info/0000755000175100017510000000000015252526356014605 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.5853484 echo-0.16.0/echo.egg-info/PKG-INFO0000644000175100017510000000520715252526356015706 0ustar00runnerrunnerMetadata-Version: 2.4 Name: echo Version: 0.16.0 Summary: Callback Properties in Python Author-email: Chris Beaumont and Thomas Robitaille Maintainer-email: Chris Beaumont and Thomas Robitaille License-Expression: MIT Project-URL: homepage, https://github.com/glue-viz/echo Classifier: Intended Audience :: Developers Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: numpy Provides-Extra: docs Requires-Dist: numpydoc; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx-automodapi; extra == "docs" Requires-Dist: sphinx-rtd-theme; extra == "docs" Provides-Extra: qt Requires-Dist: pyqt5>=5.14; extra == "qt" Requires-Dist: qtpy; extra == "qt" Provides-Extra: vue Requires-Dist: traitlets; extra == "vue" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-cov; extra == "test" Requires-Dist: ipywidgets; extra == "test" Dynamic: license-file |CI Status| |Coverage Status| |PyPI Version| echo: Callback Properties in Python =================================== Echo is a small library for attaching callback functions to property state changes. For example: :: class Switch(object): state = CallbackProperty('off') def report_change(state): print 'the switch is %s' % state s = Switch() add_callback(s, 'state', report_change) s.state = 'on' # prints 'the switch is on' CalllbackProperties can also be built using decorators :: class Switch(object): @callback_property def state(self): return self._state @state.setter def state(self, value): if value not in ['on', 'off']: raise ValueError("invalid setting") self._state = value Full documentation is avilable `here `__ .. |CI Status| image:: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml/badge.svg :target: https://github.com/glue-viz/echo/actions/workflows/ci_workflows.yml .. |Coverage Status| image:: https://codecov.io/gh/glue-viz/echo/branch/main/graph/badge.svg :target: https://codecov.io/gh/glue-viz/echo .. |PyPI Version| image:: https://img.shields.io/pypi/v/echo.svg :target: https://pypi.org/project/echo/ ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570285.60106 echo-0.16.0/echo.egg-info/SOURCES.txt0000644000175100017510000000252315252526356016473 0ustar00runnerrunner.coveragerc .gitignore .pre-commit-config.yaml .readthedocs.yml CHANGES.md LICENSE MANIFEST.in README.rst pyproject.toml tox.ini .github/dependabot.yml .github/release.yml .github/workflows/ci_workflows.yml .github/workflows/update-changelog.yaml doc/Makefile doc/api.rst doc/conf.py doc/core.rst doc/gui.rst doc/index.rst doc/installation.rst doc/vue.rst echo/__init__.py echo/alias.py echo/callback_container.py echo/conftest.py echo/containers.py echo/core.py echo/selection.py echo/version.py echo.egg-info/PKG-INFO echo.egg-info/SOURCES.txt echo.egg-info/dependency_links.txt echo.egg-info/requires.txt echo.egg-info/scm_file_list.json echo.egg-info/scm_version.json echo.egg-info/top_level.txt echo.egg-info/zip-safe echo/qt/__init__.py echo/qt/autoconnect.py echo/qt/connect.py echo/qt/tests/__init__.py echo/qt/tests/helpers.py echo/qt/tests/test_autoconnect.py echo/qt/tests/test_connect.py echo/qt/tests/test_connect_combo_selection.py echo/qt/tests/test_connect_list_selection.py echo/tests/__init__.py echo/tests/test_alias.py echo/tests/test_callback_container.py echo/tests/test_containers.py echo/tests/test_core.py echo/tests/test_selection.py echo/vue/__init__.py echo/vue/_autoconnect.py echo/vue/_connect.py echo/vue/_log.py echo/vue/tests/__init__.py echo/vue/tests/test_autoconnect.py echo/vue/tests/test_connect.py echo/vue/tests/test_log.py././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789570285.58732 echo-0.16.0/echo.egg-info/dependency_links.txt0000644000175100017510000000000115252526356020653 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.5879385 echo-0.16.0/echo.egg-info/requires.txt0000644000175100017510000000021615252526356017204 0ustar00runnerrunnernumpy [docs] numpydoc sphinx sphinx-automodapi sphinx-rtd-theme [qt] pyqt5>=5.14 qtpy [test] pytest pytest-cov ipywidgets [vue] traitlets ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.5841093 echo-0.16.0/echo.egg-info/scm_file_list.json0000644000175100017510000000277115252526356020323 0ustar00runnerrunner{ "files": [ ".coveragerc", ".github/dependabot.yml", ".github/release.yml", ".github/workflows/ci_workflows.yml", ".github/workflows/update-changelog.yaml", ".gitignore", ".pre-commit-config.yaml", ".readthedocs.yml", "CHANGES.md", "LICENSE", "MANIFEST.in", "README.rst", "doc/Makefile", "doc/api.rst", "doc/conf.py", "doc/core.rst", "doc/gui.rst", "doc/index.rst", "doc/installation.rst", "doc/vue.rst", "echo/__init__.py", "echo/alias.py", "echo/callback_container.py", "echo/conftest.py", "echo/containers.py", "echo/core.py", "echo/qt/__init__.py", "echo/qt/autoconnect.py", "echo/qt/connect.py", "echo/qt/tests/__init__.py", "echo/qt/tests/helpers.py", "echo/qt/tests/test_autoconnect.py", "echo/qt/tests/test_connect.py", "echo/qt/tests/test_connect_combo_selection.py", "echo/qt/tests/test_connect_list_selection.py", "echo/selection.py", "echo/tests/__init__.py", "echo/tests/test_alias.py", "echo/tests/test_callback_container.py", "echo/tests/test_containers.py", "echo/tests/test_core.py", "echo/tests/test_selection.py", "echo/version.py", "echo/vue/__init__.py", "echo/vue/_autoconnect.py", "echo/vue/_connect.py", "echo/vue/_log.py", "echo/vue/tests/__init__.py", "echo/vue/tests/test_autoconnect.py", "echo/vue/tests/test_connect.py", "echo/vue/tests/test_log.py", "pyproject.toml", "tox.ini" ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.5759194 echo-0.16.0/echo.egg-info/scm_version.json0000644000175100017510000000024115252526356020024 0ustar00runnerrunner{ "tag": "0.16.0", "distance": 0, "node": "g35c37ed7438741f2fdca41a22212e430acc45fa6", "dirty": false, "branch": "HEAD", "node_date": "2026-09-16" } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.5881166 echo-0.16.0/echo.egg-info/top_level.txt0000644000175100017510000000000515252526356017332 0ustar00runnerrunnerecho ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570285.3059652 echo-0.16.0/echo.egg-info/zip-safe0000644000175100017510000000000115252526355016234 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7437346 echo-0.16.0/pyproject.toml0000644000175100017510000000301615252526320015100 0ustar00runnerrunner[project] name = "echo" description = "Callback Properties in Python" readme = "README.rst" license = "MIT" maintainers = [ { name = "Chris Beaumont and Thomas Robitaille", email = "thomas.robitaille@gmail.com" } ] authors = [ { name = "Chris Beaumont and Thomas Robitaille", email = "thomas.robitaille@gmail.com" } ] requires-python = ">=3.10" classifiers = [ "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dynamic = [ "version" ] dependencies = [ "numpy", ] [tool.setuptools_scm] [project.optional-dependencies] docs = [ "numpydoc", "sphinx", "sphinx-automodapi", "sphinx-rtd-theme", ] qt = [ "pyqt5>=5.14", "qtpy", ] vue = [ "traitlets", ] test = [ "pytest", "pytest-cov", "ipywidgets", ] [project.urls] homepage = "https://github.com/glue-viz/echo" [build-system] build-backend = "setuptools.build_meta" requires = [ "setuptools>=61.2", "setuptools-scm", ] [tool.setuptools] zip-safe = true include-package-data = false [tool.setuptools.packages] find = { namespaces = false } [tool.ruff] line-length = 120 [tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "UP", # pyupgrade ] [tool.ruff.lint.per-file-ignores] "doc/conf.py" = ["E402"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789570285.620905 echo-0.16.0/setup.cfg0000644000175100017510000000004615252526356014016 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789570255.7437346 echo-0.16.0/tox.ini0000644000175100017510000000250615252526320013502 0ustar00runnerrunner[tox] envlist = py{310,311,312,313}-{test,docs}-{pyqt514,pyqt515,pyside514,pyside515,pyqt63,pyqt64,pyqt65,pyqt66,pyqt67,pyqt68,pyside63,pyside64,pyside65,pyside66,pyside67,pyside68,vue} requires = pip >= 18.0 setuptools >= 30.3.0 [testenv] setenv = COVERAGE_FILE={toxinidir}/.coverage passenv = DISPLAY HOME changedir = test: .tmp/{envname} docs: doc deps = pyqt{514,515,63,64,65,66,67,68},pyside{514,515,63,64,65,66,67,68}: qtpy>=2.0 pyqt514: PyQt5==5.14.* pyqt515: PyQt5==5.15.* pyside514: PySide2==5.14.* pyside515: PySide2==5.15.2.1 pyqt63: PyQt6==6.3.* pyqt63: PyQt6-Qt6==6.3.* pyqt64: PyQt6==6.4.* pyqt64: PyQt6-Qt6==6.4.* pyqt65: PyQt6==6.5.* pyqt65: PyQt6-Qt6==6.5.* pyqt66: PyQt6==6.6.* pyqt66: PyQt6-Qt6==6.6.* pyqt67: PyQt6==6.7.* pyqt67: PyQt6-Qt6==6.7.* pyqt68: PyQt6==6.8.* pyqt68: PyQt6-Qt6==6.8.* pyside63: PySide6==6.3.* pyside64: PySide6==6.4.* pyside65: PySide6==6.5.* pyside66: PySide6==6.6.* pyside67: PySide6==6.7.* pyside68: PySide6==6.8.* vue: traitlets extras = test docs: docs docs: vue commands = test: pip freeze test: pytest --pyargs echo --cov echo --cov-config={toxinidir}/pyproject.toml {posargs} docs: sphinx-build -n -b html -d _build/doctrees . _build/html